Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 37d4b317b90c0e7ffb8f9ac47e58263f0c53db7d


Parents : df306cc
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-07T19:13:20-06:00

feat(ui): enhance user interface and functionality across multiple components

- Updated sidebar width in App.vue for better layout.
- Added navigation option for RNPath trace in CommandPalette.vue.
- Included Italian language support in LanguageSelector.vue.
- Improved Toast.vue to handle loading state for toasts and update existing toasts.
- Enhanced AboutPage.vue with download buttons for snapshots and backups.
- Refined InterfacesPage.vue to improve layout and filtering capabilities.
- Introduced MiniChat.vue for a compact chat interface on the map.
- Updated ConversationDropDownMenu.vue to include telemetry trust toggle.
- Enhanced ConversationViewer.vue with better telemetry handling and error notifications.
- Added RNPathTracePage.vue for tracing paths to destination hashes.
- Improved ToolsPage.vue to include RNPath trace functionality.

Changes
Diff

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index fa2fe5cb..3d154854 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -129,7 +129,7 @@
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
- isSidebarCollapsed ? 'w-16' : 'w-72',
+ isSidebarCollapsed ? 'w-16' : 'w-80',
]"
>
<div

diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index 2bf99542..c12a70c9 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -215,6 +215,14 @@ export default {
type: "navigation",
route: { name: "rnpath" },
},
+ {
+ id: "nav-rnpath-trace",
+ title: "nav_rnpath_trace",
+ description: "nav_rnpath_trace_desc",
+ icon: "map-marker-path",
+ type: "navigation",
+ route: { name: "rnpath-trace" },
+ },
{
id: "nav-translator",
title: "nav_translator",

diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue
index 7ad6d8de..c73151bd 100644
--- a/meshchatx/src/frontend/components/LanguageSelector.vue
+++ b/meshchatx/src/frontend/components/LanguageSelector.vue
@@ -70,6 +70,7 @@ export default {
{ code: "en", name: "English" },
{ code: "de", name: "Deutsch" },
{ code: "ru", name: "Русский" },
+ { code: "it", name: "Italiano" },
],
};
},

diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index 1be846c8..d5c71929 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -24,6 +24,11 @@
icon-name="alert"
class="h-6 w-6 text-amber-500"
/>
+ <MaterialDesignIcon
+ v-else-if="toast.type === 'loading'"
+ icon-name="loading"
+ class="h-6 w-6 text-blue-500 animate-spin"
+ />
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
</div>
@@ -70,24 +75,58 @@ export default {
},
methods: {
add(toast) {
+ // Check if a toast with the same key already exists
+ if (toast.key) {
+ const existingIndex = this.toasts.findIndex((t) => t.key === toast.key);
+ if (existingIndex !== -1) {
+ const existingToast = this.toasts[existingIndex];
+
+ // Clear existing timeout if it exists
+ if (existingToast.timer) {
+ clearTimeout(existingToast.timer);
+ }
+
+ // Update existing toast
+ existingToast.message = toast.message;
+ existingToast.type = toast.type || "info";
+ existingToast.duration = toast.duration !== undefined ? toast.duration : 5000;
+
+ if (existingToast.duration > 0) {
+ existingToast.timer = setTimeout(() => {
+ this.remove(existingToast.id);
+ }, existingToast.duration);
+ } else {
+ existingToast.timer = null;
+ }
+ return;
+ }
+ }
+
const id = this.counter++;
const newToast = {
id,
+ key: toast.key,
message: toast.message,
type: toast.type || "info",
- duration: toast.duration || 5000,
+ duration: toast.duration !== undefined ? toast.duration : 5000,
+ timer: null,
};
- this.toasts.push(newToast);
if (newToast.duration > 0) {
- setTimeout(() => {
+ newToast.timer = setTimeout(() => {
this.remove(id);
}, newToast.duration);
}
+
+ this.toasts.push(newToast);
},
remove(id) {
const index = this.toasts.findIndex((t) => t.id === id);
if (index !== -1) {
+ const toast = this.toasts[index];
+ if (toast.timer) {
+ clearTimeout(toast.timer);
+ }
this.toasts.splice(index, 1);
}
},

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 96f440ff..00343b34 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -402,7 +402,7 @@
>
{{
appInfo.is_connected_to_shared_instance
- ? "Shared Instance"
+ ? `Shared Instance: ${appInfo.shared_instance_address || "unknown"}`
: "Main Instance"
}}
</div>
@@ -659,6 +659,14 @@
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
+ <button
+ type="button"
+ class="primary-chip !px-3 !py-1 !text-[10px]"
+ @click="downloadSnapshot(snapshot.name)"
+ >
+ <v-icon icon="mdi-download" size="12" start></v-icon>
+ Download
+ </button>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@@ -742,6 +750,14 @@
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
+ <button
+ type="button"
+ class="primary-chip !px-3 !py-1 !text-[10px]"
+ @click="downloadBackupFile(backup.name)"
+ >
+ <v-icon icon="mdi-download" size="12" start></v-icon>
+ Download
+ </button>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@@ -1001,6 +1017,42 @@ export default {
console.log("Failed to list auto-backups");
}
},
+ async downloadSnapshot(filename) {
+ try {
+ const response = await window.axios.get(`/api/v1/database/snapshots/${filename}/download`, {
+ responseType: "blob",
+ });
+ const url = window.URL.createObjectURL(new Blob([response.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.setAttribute("download", filename);
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ ToastUtils.success("Snapshot downloaded");
+ } catch {
+ ToastUtils.error("Failed to download snapshot");
+ }
+ },
+ async downloadBackupFile(filename) {
+ try {
+ const response = await window.axios.get(`/api/v1/database/backups/${filename}/download`, {
+ responseType: "blob",
+ });
+ const url = window.URL.createObjectURL(new Blob([response.data]));
+ const link = document.createElement("a");
+ link.href = url;
+ link.setAttribute("download", filename);
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ ToastUtils.success("Backup downloaded");
+ } catch {
+ ToastUtils.error("Failed to download backup");
+ }
+ },
async deleteSnapshot(filename) {
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
try {

diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 51d2ec22..60cdd4f8 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -26,20 +26,18 @@
</button>
</div>
- <div class="glass-card space-y-4">
- <div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1 min-w-0">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- {{ $t("interfaces.manage") }}
- </div>
- <div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
- {{ $t("interfaces.title") }}
- </div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- {{ $t("interfaces.description") }}
- </div>
+ <div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
+ <div class="space-y-3 flex-1 min-w-0">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("interfaces.manage") }}
+ </div>
+ <div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
+ {{ $t("interfaces.title") }}
</div>
- <div class="flex flex-wrap gap-2">
+ <div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
+ {{ $t("interfaces.description") }}
+ </div>
+ <div class="flex flex-wrap gap-2 pt-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
{{ $t("interfaces.add_interface") }}
@@ -52,56 +50,34 @@
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
{{ $t("interfaces.export_all") }}
</button>
- <!--
- <button
- type="button"
- class="secondary-chip text-sm bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
- :disabled="reloadingRns"
- @click="reloadRns"
- >
+ </div>
+ </div>
+
+ <div class="w-full md:w-96 shrink-0 space-y-4">
+ <div class="relative group">
<MaterialDesignIcon
- :icon-name="reloadingRns ? 'refresh' : 'restart'"
- class="w-4 h-4"
- :class="{ 'animate-spin-reverse': reloadingRns }"
+ icon-name="magnify"
+ class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
/>
- {{ reloadingRns ? $t("app.reloading_rns") : $t("app.reload_rns") }}
- </button>
- --></div>
- </div>
- <div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1">
<input
v-model="searchTerm"
type="text"
:placeholder="$t('interfaces.search_placeholder')"
- class="input-field"
+ class="w-full pl-12 pr-4 py-3 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm"
/>
- </div>
- <div class="flex gap-2 flex-wrap">
- <button
- type="button"
- :class="filterChipClass(statusFilter === 'all')"
- @click="setStatusFilter('all')"
- >
- {{ $t("interfaces.all") }}
- </button>
<button
- type="button"
- :class="filterChipClass(statusFilter === 'enabled')"
- @click="setStatusFilter('enabled')"
+ v-if="searchTerm"
+ class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+ @click="searchTerm = ''"
>
- {{ $t("app.enabled") }}
- </button>
- <button
- type="button"
- :class="filterChipClass(statusFilter === 'disabled')"
- @click="setStatusFilter('disabled')"
- >
- {{ $t("app.disabled") }}
+ <MaterialDesignIcon icon-name="close-circle" class="size-5" />
</button>
</div>
- <div class="w-full sm:w-60">
- <select v-model="typeFilter" class="input-field">
+ <div>
+ <select
+ v-model="typeFilter"
+ class="w-full px-4 py-2.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-gray-900 dark:text-white"
+ >
<option value="all">{{ $t("interfaces.all_types") }}</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
{{ type }}
@@ -127,10 +103,47 @@
<div v-if="activeTab === 'overview'" class="space-y-4">
<div class="glass-card space-y-3">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- Configured
+ <div class="flex flex-wrap items-center justify-between gap-4">
+ <div class="space-y-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Configured
+ </div>
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
+ Interfaces
+ <span
+ v-if="filteredInterfaces.length > 0"
+ class="ml-2 text-sm font-medium text-gray-400"
+ >({{ filteredInterfaces.length }})</span
+ >
+ </div>
+ </div>
+ <div class="flex gap-2 flex-wrap">
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'all')"
+ class="!py-1 !px-3"
+ @click="setStatusFilter('all')"
+ >
+ {{ $t("interfaces.all") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'enabled')"
+ class="!py-1 !px-3"
+ @click="setStatusFilter('enabled')"
+ >
+ {{ $t("app.enabled") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'disabled')"
+ class="!py-1 !px-3"
+ @click="setStatusFilter('disabled')"
+ >
+ {{ $t("app.disabled") }}
+ </button>
+ </div>
</div>
- <div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
<div
v-if="filteredInterfaces.length !== 0"
class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
@@ -162,13 +175,36 @@
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
Recently Heard Announces
+ <span
+ v-if="sortedDiscoveredInterfaces.length > 0"
+ class="ml-2 text-sm font-medium text-gray-400"
+ >({{ sortedDiscoveredInterfaces.length }})</span
+ >
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Discovery runs continually; heard announces stay listed. Connected entries show
a green pill; disconnected entries are dimmed with a red label.
</div>
</div>
- <div class="flex gap-2">
+ <div class="flex gap-2 flex-wrap items-center">
+ <div class="flex gap-1.5 mr-2">
+ <button
+ type="button"
+ :class="filterChipClass(discoveredStatusFilter === 'all')"
+ class="!py-1 !px-3"
+ @click="discoveredStatusFilter = 'all'"
+ >
+ {{ $t("interfaces.all") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClass(discoveredStatusFilter === 'connected')"
+ class="!py-1 !px-3"
+ @click="discoveredStatusFilter = 'connected'"
+ >
+ {{ $t("interfaces.connected_only") }}
+ </button>
+ </div>
<button
v-if="interfacesWithLocation.length > 0"
type="button"
@@ -213,7 +249,8 @@
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
>
<div
- class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
+ class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider"
+ :class="{ 'animate-pulse': shouldAnimatePulse(iface) }"
>
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
<span>{{ $t("app.disabled") }}</span>
@@ -522,6 +559,7 @@ export default {
savingDiscovery: false,
discoveredInterfaces: [],
discoveredActive: [],
+ discoveredStatusFilter: "all",
discoveryInterval: null,
activeTab: "overview",
};
@@ -596,7 +634,31 @@ export default {
return Array.from(types).sort();
},
sortedDiscoveredInterfaces() {
- return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
+ const search = this.searchTerm.toLowerCase().trim();
+ let list = [...this.discoveredInterfaces];
+ if (this.discoveredStatusFilter === "connected") {
+ list = list.filter((iface) => this.isDiscoveredConnected(iface));
+ }
+ if (this.typeFilter !== "all") {
+ list = list.filter((iface) => iface.type === this.typeFilter);
+ }
+ if (search) {
+ list = list.filter((iface) => {
+ const haystack = [
+ iface.name,
+ iface.type,
+ iface.reachable_on,
+ iface.port,
+ iface.transport_id,
+ iface.network_id,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(search);
+ });
+ }
+ return list.sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
},
interfacesWithLocation() {
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
@@ -806,6 +868,7 @@ export default {
...iface,
last_heard: lastHeard,
__isNew: isNew || existing?.__isNew,
+ disconnected_at: existing?.disconnected_at ?? null,
});
};
@@ -814,6 +877,18 @@ export default {
this.discoveredInterfaces = Array.from(merged.values());
this.discoveredActive = active;
+
+ // Track disconnection time for animation control
+ const now = Date.now();
+ this.discoveredInterfaces.forEach((iface) => {
+ if (!this.isDiscoveredConnected(iface)) {
+ if (iface.disconnected_at === null) {
+ iface.disconnected_at = now;
+ }
+ } else {
+ iface.disconnected_at = null;
+ }
+ });
} catch (e) {
console.log(e);
}
@@ -851,6 +926,13 @@ export default {
return hostMatch && portMatch && (s.connected || s.online);
});
},
+ shouldAnimatePulse(iface) {
+ if (this.isDiscoveredConnected(iface)) return false;
+ if (!iface.disconnected_at) return true;
+ // only pulse for the first 30 seconds of being disconnected
+ // to avoid continuous UI animation lag on many disconnected items
+ return Date.now() - iface.disconnected_at < 30000;
+ },
goToMap(iface) {
if (iface.latitude == null || iface.longitude == null) return;
this.$router.push({

diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index b5a64561..0fb38acb 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -23,7 +23,7 @@
:class="
!offlineEnabled
? 'bg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400'
- : 'text-gray-500 dark:text-zinc-400'
+ : 'text-gray-500 dark:text-gray-300'
"
class="px-3 py-1 text-sm font-medium rounded-md transition-all"
@click="toggleOffline(false)"
@@ -34,7 +34,7 @@
:class="
offlineEnabled
? 'bg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400'
- : 'text-gray-500 dark:text-zinc-400'
+ : 'text-gray-500 dark:text-gray-300'
"
class="px-3 py-1 text-sm font-medium rounded-md transition-all"
:disabled="!hasOfflineMap"
@@ -56,7 +56,7 @@
<!-- settings button -->
<button
- class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
+ class="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors"
@click="isSettingsOpen = !isSettingsOpen"
>
<MaterialDesignIcon icon-name="cog" class="size-5" />
@@ -81,7 +81,7 @@
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
- : 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400',
+ : 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
@@ -94,7 +94,7 @@
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
- : 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400',
+ : 'hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300',
]"
:title="$t('map.tool_measure')"
@click="toggleMeasure"
@@ -350,6 +350,7 @@
<div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
+ v-if="selectedMarker.telemetry || selectedMarker.peer"
class="size-8 rounded-full flex items-center justify-center border-2"
:style="{
color: selectedMarker.peer?.lxmf_user_icon?.foreground_colour || '#3b82f6',
@@ -362,79 +363,166 @@
size="18"
></v-icon>
</div>
+ <div
+ v-else-if="selectedMarker.discovered"
+ class="size-8 rounded-full flex items-center justify-center border-2 border-emerald-500 bg-emerald-50 text-emerald-600"
+ >
+ <v-icon icon="mdi-router-wireless" size="18"></v-icon>
+ </div>
<div>
<h3 class="font-bold text-gray-900 dark:text-zinc-100 truncate w-40">
{{
+ selectedMarker.discovered?.name ||
selectedMarker.peer?.display_name ||
- selectedMarker.telemetry.destination_hash.substring(0, 8)
+ selectedMarker.telemetry?.destination_hash.substring(0, 8)
}}
</h3>
- <div class="text-[10px] font-mono text-gray-500 uppercase tracking-tighter">
+ <div
+ v-if="selectedMarker.telemetry"
+ class="text-[10px] font-mono text-gray-500 uppercase tracking-tighter"
+ >
{{ selectedMarker.telemetry.destination_hash }}
</div>
+ <div
+ v-else-if="selectedMarker.discovered"
+ class="text-[10px] font-mono text-gray-500 uppercase tracking-tighter"
+ >
+ Discovered Interface
+ </div>
</div>
</div>
- <button
- class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300"
- @click="selectedMarker = null"
- >
- <v-icon icon="mdi-close" size="20"></v-icon>
- </button>
+ <div class="flex items-center gap-1">
+ <button
+ v-if="selectedMarker.telemetry"
+ class="p-2 rounded-full transition-colors"
+ :class="
+ selectedMarker.telemetry.is_tracking
+ ? 'text-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300'
+ "
+ :title="selectedMarker.telemetry.is_tracking ? 'Stop Tracking' : 'Live Track Peer'"
+ @click="toggleTracking(selectedMarker.telemetry.destination_hash)"
+ >
+ <v-icon
+ :icon="selectedMarker.telemetry.is_tracking ? 'mdi-radar' : 'mdi-crosshairs'"
+ size="20"
+ ></v-icon>
+ </button>
+ <button
+ class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300 p-1"
+ @click="selectedMarker = null"
+ >
+ <v-icon icon="mdi-close" size="20"></v-icon>
+ </button>
+ </div>
</div>
<div class="p-4 space-y-3">
- <div class="grid grid-cols-2 gap-4 text-sm">
- <div>
- <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
- Latitude
+ <!-- Discovered Node Details -->
+ <div v-if="selectedMarker.discovered" class="space-y-3">
+ <div class="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Latitude
+ </div>
+ <div class="font-mono">
+ {{ selectedMarker.discovered.latitude.toFixed(6) }}
+ </div>
</div>
- <div class="font-mono">
- {{ selectedMarker.telemetry.telemetry.location.latitude.toFixed(6) }}
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Longitude
+ </div>
+ <div class="font-mono">
+ {{ selectedMarker.discovered.longitude.toFixed(6) }}
+ </div>
</div>
</div>
- <div>
- <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
- Longitude
+
+ <div class="pt-2 border-t border-gray-100 dark:border-zinc-800 space-y-2">
+ <div v-if="selectedMarker.discovered.interface" class="flex justify-between items-center">
+ <span class="text-[10px] font-bold text-gray-400 uppercase">Interface</span>
+ <span class="text-xs font-mono">{{ selectedMarker.discovered.interface }}</span>
</div>
- <div class="font-mono">
- {{ selectedMarker.telemetry.telemetry.location.longitude.toFixed(6) }}
+ <div v-if="selectedMarker.discovered.via" class="flex justify-between items-center">
+ <span class="text-[10px] font-bold text-gray-400 uppercase">Via</span>
+ <span class="text-xs font-mono">{{ selectedMarker.discovered.via }}</span>
+ </div>
+ <div
+ v-if="selectedMarker.discovered.hops != null"
+ class="flex justify-between items-center"
+ >
+ <span class="text-[10px] font-bold text-gray-400 uppercase">Hops</span>
+ <span class="text-xs">{{ selectedMarker.discovered.hops }}</span>
</div>
</div>
- <div>
- <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
- Altitude
+ </div>
+
+ <!-- Telemetry Details -->
+ <div v-if="selectedMarker.telemetry" class="space-y-3">
+ <div class="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Latitude
+ </div>
+ <div class="font-mono">
+ {{ selectedMarker.telemetry.telemetry.location.latitude.toFixed(6) }}
+ </div>
+ </div>
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Longitude
+ </div>
+ <div class="font-mono">
+ {{ selectedMarker.telemetry.telemetry.location.longitude.toFixed(6) }}
+ </div>
+ </div>
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Altitude
+ </div>
+ <div>{{ selectedMarker.telemetry.telemetry.location.altitude.toFixed(1) }}m</div>
+ </div>
+ <div>
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">
+ Speed
+ </div>
+ <div>{{ selectedMarker.telemetry.telemetry.location.speed.toFixed(1) }}km/h</div>
</div>
- <div>{{ selectedMarker.telemetry.telemetry.location.altitude.toFixed(1) }}m</div>
</div>
- <div>
- <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-0.5">Speed</div>
- <div>{{ selectedMarker.telemetry.telemetry.location.speed.toFixed(1) }}km/h</div>
+
+ <div
+ v-if="selectedMarker.telemetry.physical_link"
+ class="pt-2 border-t border-gray-100 dark:border-zinc-800"
+ >
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Signal</div>
+ <div class="flex gap-4 text-xs font-mono">
+ <span>RSSI: {{ selectedMarker.telemetry.physical_link.rssi }}</span>
+ <span>SNR: {{ selectedMarker.telemetry.physical_link.snr }}</span>
+ <span>Q: {{ selectedMarker.telemetry.physical_link.q }}%</span>
+ </div>
</div>
- </div>
- <div
- v-if="selectedMarker.telemetry.physical_link"
- class="pt-2 border-t border-gray-100 dark:border-zinc-800"
- >
- <div class="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Signal</div>
- <div class="flex gap-4 text-xs font-mono">
- <span>RSSI: {{ selectedMarker.telemetry.physical_link.rssi }}</span>
- <span>SNR: {{ selectedMarker.telemetry.physical_link.snr }}</span>
- <span>Q: {{ selectedMarker.telemetry.physical_link.q }}%</span>
+ <div class="pt-2 text-[10px] text-gray-400 flex items-center gap-1">
+ <v-icon icon="mdi-clock-outline" size="12"></v-icon>
+ Updated: {{ formatTimestamp(selectedMarker.telemetry.timestamp) }}
</div>
- </div>
- <div class="pt-2 text-[10px] text-gray-400 flex items-center gap-1">
- <v-icon icon="mdi-clock-outline" size="12"></v-icon>
- Updated: {{ formatTimestamp(selectedMarker.telemetry.timestamp) }}
+ <div class="border-t border-gray-100 dark:border-zinc-800 pt-3">
+ <button
+ class="w-full py-2 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 rounded-lg font-bold transition-all text-sm flex items-center justify-center gap-2 mb-2"
+ @click="isMiniChatOpen = !isMiniChatOpen"
+ >
+ <v-icon
+ :icon="isMiniChatOpen ? 'mdi-chevron-up' : 'mdi-message-text'"
+ size="16"
+ ></v-icon>
+ {{ isMiniChatOpen ? "Hide Mini-Chat" : "Show Mini-Chat" }}
+ </button>
+ <div v-if="isMiniChatOpen">
+ <MiniChat :destination-hash="selectedMarker.telemetry.destination_hash" />
+ </div>
+ </div>
</div>
-
- <button
- class="w-full py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-bold transition-colors text-sm flex items-center justify-center gap-2"
- @click="openChat(selectedMarker.telemetry.destination_hash)"
- >
- <v-icon icon="mdi-message-text" size="16"></v-icon>
- Open Chat
- </button>
</div>
</div>
@@ -545,14 +633,21 @@
</div>
</div>
- <div v-if="exportStatus.status === 'completed'">
+ <div v-if="exportStatus.status === 'completed'" class="flex flex-col gap-2">
<a
:href="`/api/v1/map/export/${exportId}/download`"
- class="flex items-center justify-center space-x-2 w-full py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-bold transition-colors shadow-md"
+ class="flex items-center justify-center space-x-2 w-full py-2 bg-emerald-500 hover:bg-emerald-600 text-white rounded-lg font-bold transition-colors shadow-md text-xs"
>
<MaterialDesignIcon icon-name="download" class="size-4" />
<span>{{ $t("map.download_now") }}</span>
</a>
+ <button
+ class="flex items-center justify-center space-x-2 w-full py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-bold transition-colors shadow-md text-xs"
+ @click="isSettingsOpen = true"
+ >
+ <MaterialDesignIcon icon-name="map-check" class="size-4" />
+ <span>Show in Offline Maps</span>
+ </button>
</div>
<div
@@ -628,162 +723,322 @@
</div>
</div>
+ // controls overlay -->
<!-- controls overlay -->
<div
v-if="isSettingsOpen"
- class="absolute top-14 right-4 z-20 w-64 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden"
+ class="absolute top-14 right-4 z-20 w-72 max-h-[calc(100vh-5rem)] bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200"
>
- <div class="p-4 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between">
- <h3 class="font-semibold text-gray-900 dark:text-zinc-100">{{ $t("app.settings") }}</h3>
+ <div
+ class="p-3 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-zinc-800/50"
+ >
+ <div class="flex items-center space-x-2">
+ <MaterialDesignIcon icon-name="cog" class="size-4 text-gray-500 dark:text-gray-300" />
+ <h3 class="font-bold text-gray-900 dark:text-zinc-100 text-xs uppercase tracking-widest">
+ {{ $t("app.settings") }}
+ </h3>
+ </div>
<button
- class="text-gray-500 hover:text-gray-700 dark:hover:text-zinc-300"
+ class="p-1 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors text-gray-500 dark:text-gray-300"
@click="isSettingsOpen = false"
>
- <MaterialDesignIcon icon-name="close" class="size-5" />
+ <MaterialDesignIcon icon-name="close" class="size-4" />
</button>
</div>
- <div class="p-4 space-y-4">
- <div>
+
+ <div class="p-3 space-y-4 overflow-y-auto scrollbar-thin flex-1">
+ <!-- Quick Actions -->
+ <div class="grid grid-cols-2 gap-2">
<button
- class="w-full flex items-center justify-center space-x-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-900 dark:text-zinc-100 rounded-lg transition-colors text-sm font-medium"
+ class="flex items-center justify-center space-x-1.5 px-2 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight shadow-sm active:scale-95"
@click="setAsDefaultView"
>
- <MaterialDesignIcon icon-name="pin" class="size-4" />
- <span>{{ $t("map.set_as_default") }}</span>
+ <MaterialDesignIcon icon-name="pin" class="size-3" />
+ <span>Set Default</span>
+ </button>
+
+ <button
+ class="flex items-center justify-center space-x-1.5 px-2 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight active:scale-95"
+ @click="clearCache"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-3" />
+ <span>Clear Cache</span>
</button>
</div>
- <div v-if="!offlineEnabled" class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
- <div>
- <label class="block text-xs font-bold text-gray-500 uppercase mb-2">Preset Servers</label>
- <div class="grid grid-cols-1 gap-2 mb-4">
- <button
- class="px-3 py-2 text-xs font-semibold rounded-lg transition-all border"
- :class="
- tileServerUrl.includes('openstreetmap.org')
- ? 'bg-blue-500 border-blue-600 text-white shadow-sm'
- : 'bg-white dark:bg-zinc-800 border-gray-200 dark:border-zinc-700 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-700'
- "
- @click="setTileServer('osm')"
- >
- {{ $t("map.tile_server_openstreetmap") }}
- </button>
- </div>
+ <!-- Map Style Presets -->
+ <div v-if="!offlineEnabled" class="space-y-2">
+ <div class="flex items-center justify-between">
+ <label class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
+ >Map Styles</label
+ >
+ <div class="h-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3"></div>
+ </div>
+ <div class="grid grid-cols-4 gap-1">
+ <button
+ v-for="style in [
+ { id: 'osm', label: 'OSM' },
+ { id: 'carto-dark', label: 'Dark' },
+ { id: 'carto-voyager', label: 'Voy' },
+ { id: 'carto-light', label: 'Lite' },
+ ]"
+ :key="style.id"
+ class="py-1.5 text-[8px] font-bold uppercase rounded-md transition-all border leading-tight"
+ :class="
+ (style.id === 'osm' && tileServerUrl.includes('openstreetmap.org')) ||
+ (style.id === 'carto-dark' &&
+ tileServerUrl.includes('basemaps.cartocdn.com/dark_all')) ||
+ (style.id === 'carto-voyager' && tileServerUrl.includes('rastertiles/voyager')) ||
+ (style.id === 'carto-light' &&
+ tileServerUrl.includes('basemaps.cartocdn.com/light_all'))
+ ? 'bg-blue-500 border-blue-600 text-white shadow-sm ring-2 ring-blue-500/20'
+ : 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500 dark:text-zinc-400 hover:bg-gray-50 dark:hover:bg-zinc-800'
+ "
+ @click="setTileServer(style.id)"
+ >
+ {{ style.label }}
+ </button>
+ </div>
- <label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
- $t("map.tile_server_url")
- }}</label>
+ <div class="space-y-1">
+ <label
+ class="text-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-center"
+ >
+ <MaterialDesignIcon icon-name="link-variant" class="size-3 mr-1" />
+ Tile Server URL
+ </label>
<input
v-model="tileServerUrl"
type="text"
- class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
+ class="w-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-none"
:placeholder="$t('map.tile_server_url_placeholder')"
@blur="saveTileServerUrl"
/>
- <p class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
- {{ $t("map.tile_server_url_hint") }}
- </p>
</div>
- <div>
- <label class="block text-xs font-bold text-gray-500 uppercase mb-1">{{
- $t("map.nominatim_api_url")
- }}</label>
+
+ <div class="space-y-1">
+ <label
+ class="text-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-center"
+ >
+ <MaterialDesignIcon icon-name="magnify" class="size-3 mr-1" />
+ Geocoder API
+ </label>
<input
v-model="nominatimApiUrl"
type="text"
- class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
+ class="w-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-none"
:placeholder="$t('map.nominatim_api_url_placeholder')"
@blur="saveNominatimApiUrl"
/>
- <p class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
- {{ $t("map.nominatim_api_url_hint") }}
- </p>
</div>
</div>
- <div class="flex items-center justify-between py-2 border-t border-gray-100 dark:border-zinc-800">
- <span class="text-sm text-gray-700 dark:text-zinc-300">{{ $t("map.caching_enabled") }}</span>
- <Toggle :model-value="cachingEnabled" @update:model-value="toggleCaching" />
+ <!-- Live Tracking -->
+ <div class="space-y-2">
+ <div class="flex items-center justify-between">
+ <label class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
+ >Live Tracking</label
+ >
+ <div class="h-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3"></div>
+ </div>
+ <div v-if="trackedPeers.length === 0" class="text-[10px] text-gray-500 italic px-2">
+ No peers currently being tracked.
+ </div>
+ <div v-else class="space-y-1">
+ <div
+ v-for="peer in trackedPeers"
+ :key="peer.destination_hash"
+ class="flex items-center justify-between p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg group"
+ >
+ <div class="flex flex-col min-w-0">
+ <span class="text-[10px] font-bold text-gray-900 dark:text-zinc-100 truncate">
+ {{
+ peers[peer.destination_hash]?.display_name ||
+ peer.destination_hash.substring(0, 8)
+ }}
+ </span>
+ <span class="text-[8px] text-gray-500 font-mono">
+ {{ peer.destination_hash }}
+ </span>
+ </div>
+ <button
+ class="p-1 text-red-400 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100"
+ title="Stop Tracking"
+ @click="toggleTracking(peer.destination_hash)"
+ >
+ <MaterialDesignIcon icon-name="close-circle" class="size-3" />
+ </button>
+ </div>
+ </div>
</div>
- <div class="border-t border-gray-100 dark:border-zinc-800 pt-4 space-y-4">
- <div>
- <label class="block text-xs font-bold text-gray-500 uppercase mb-1"
- >MBTiles Storage Directory</label
+ <!-- MBTiles Section -->
+ <div class="space-y-3 pt-1">
+ <div class="flex items-center justify-between">
+ <label class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
+ >Offline Maps</label
+ >
+ <div class="h-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3"></div>
+ </div>
+
+ <div
+ class="flex items-center justify-between py-1 px-2 bg-gray-50/50 dark:bg-zinc-800/30 rounded-lg border border-gray-100 dark:border-zinc-800"
+ >
+ <span
+ class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase tracking-tight"
+ >Tile Caching</span
+ >
+ <Toggle :model-value="cachingEnabled" @update:model-value="toggleCaching" />
+ </div>
+
+ <div class="space-y-1">
+ <label
+ class="text-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-center"
>
+ <MaterialDesignIcon icon-name="folder-outline" class="size-3 mr-1" />
+ Storage Path
+ </label>
<input
v-model="mbtilesDir"
type="text"
- class="w-full bg-gray-50 dark:bg-zinc-800 border border-gray-300 dark:border-zinc-700 rounded-lg px-3 py-2 text-sm dark:text-zinc-100"
- placeholder="Default storage directory"
+ class="w-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-none"
+ placeholder="Default storage"
@blur="saveMBTilesDir"
/>
</div>
- <div v-if="mbtilesList.length > 0" class="space-y-2">
- <label class="block text-xs font-bold text-gray-500 uppercase mb-1">Available Maps</label>
- <div class="max-h-48 overflow-y-auto space-y-1">
+ <div v-if="mbtilesList.length > 0" class="space-y-1.5">
+ <div class="flex items-center space-x-2 pb-0.5">
+ <MaterialDesignIcon icon-name="database-outline" class="size-3 text-blue-500" />
+ <span
+ class="text-[10px] font-bold text-gray-700 dark:text-zinc-300 uppercase tracking-tight"
+ >MBTiles Library</span
+ >
+ </div>
+ <div class="space-y-1 max-h-40 overflow-y-auto pr-1 scrollbar-thin">
<div
v-for="file in mbtilesList"
:key="file.name"
- class="flex items-center justify-between p-2 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800"
+ class="flex items-center justify-between p-2 rounded-xl"
+ :class="
+ file.is_active
+ ? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50'
+ : 'bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 shadow-sm'
+ "
>
<div class="flex flex-col min-w-0 flex-1 mr-2">
<span
- class="text-xs font-medium text-gray-900 dark:text-zinc-100 truncate"
+ class="text-[10px] font-bold text-gray-900 dark:text-zinc-100 truncate leading-none mb-1"
:title="file.name"
>{{ file.name }}</span
>
- <span class="text-[10px] text-gray-500"
- >{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span
- >
+ <div class="flex items-center space-x-2">
+ <span class="text-[8px] font-black text-gray-400 uppercase tabular-nums"
+ >{{ (file.size / 1024 / 1024).toFixed(1) }} MB</span
+ >
+ <span
+ v-if="file.is_active"
+ class="text-[8px] font-black text-blue-500 uppercase"
+ >Active</span
+ >
+ </div>
</div>
<div class="flex items-center space-x-1">
<button
v-if="!file.is_active"
- class="p-1 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
+ class="p-1.5 text-blue-500 hover:bg-blue-500 hover:text-white rounded-lg transition-all active:scale-90"
title="Set as active"
@click="setActiveMBTiles(file.name)"
>
- <MaterialDesignIcon icon-name="check" class="size-4" />
+ <MaterialDesignIcon icon-name="check" class="size-3.5" />
</button>
- <div v-else class="p-1 text-emerald-500" title="Active">
- <MaterialDesignIcon icon-name="check-circle" class="size-4" />
- </div>
<button
- class="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
+ class="p-1.5 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all active:scale-90"
title="Delete"
@click="deleteMBTiles(file.name)"
>
- <MaterialDesignIcon icon-name="delete" class="size-4" />
+ <MaterialDesignIcon icon-name="delete-outline" class="size-3.5" />
</button>
</div>
</div>
</div>
</div>
</div>
+ </div>
- <button
- class="w-full px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-950/20 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 rounded-lg transition-colors text-xs font-bold uppercase tracking-wider"
- @click="clearCache"
- >
- {{ $t("map.clear_cache") }}
- </button>
-
- <div
- class="text-xs text-gray-500 dark:text-zinc-500 space-y-1 pt-2 border-t border-gray-100 dark:border-zinc-800"
- >
- <div class="flex justify-between">
- <span>{{ $t("map.zoom") }}:</span>
- <span class="font-mono">{{ currentZoom.toFixed(1) }}</span>
+ <!-- Footer Stats -->
+ <div
+ class="p-2.5 bg-gray-50 dark:bg-zinc-800/50 border-t border-gray-200 dark:border-zinc-800 shrink-0"
+ >
+ <div class="grid grid-cols-3 gap-2">
+ <div class="flex flex-col items-center">
+ <span class="text-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5"
+ >Zoom</span
+ >
+ <span
+ class="text-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-nums"
+ >{{ currentZoom.toFixed(1) }}</span
+ >
</div>
- <div class="flex justify-between">
- <span>Lat:</span>
- <span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
+ <div class="flex flex-col items-center border-x border-gray-200 dark:border-zinc-700">
+ <span class="text-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5"
+ >Lat</span
+ >
+ <span
+ class="text-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-nums"
+ >{{ displayCoords[1].toFixed(4) }}</span
+ >
</div>
- <div class="flex justify-between">
- <span>Lon:</span>
- <span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
+ <div class="flex flex-col items-center">
+ <span class="text-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5"
+ >Lon</span
+ >
+ <span
+ class="text-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-nums"
+ >{{ displayCoords[0].toFixed(4) }}</span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- offline warning overlay -->
+ <div
+ v-if="showOfflineHint"
+ class="absolute top-14 left-1/2 -translate-x-1/2 z-30 w-full max-w-sm px-4 animate-in fade-in slide-in-from-top-4 duration-500"
+ >
+ <div
+ class="bg-amber-500 text-white rounded-xl shadow-2xl p-4 flex items-start space-x-3 border-2 border-amber-600/50"
+ >
+ <MaterialDesignIcon icon-name="wifi-off" class="size-6 shrink-0 mt-0.5" />
+ <div class="flex-1 space-y-2">
+ <p class="text-xs font-bold leading-tight">
+ Failed to fetch map tiles. You appear to be offline or off-grid.
+ </p>
+ <p class="text-[10px] opacity-90 font-medium leading-relaxed">
+ Please use an
+ <strong
+ class="font-bold text-white underline decoration-white/30 decoration-2 underline-offset-2"
+ >Offline Map</strong
+ >
+ with MBTiles, or configure a local tile/geocoder server in the map settings.
+ </p>
+ <div class="flex space-x-2 pt-1">
+ <button
+ class="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-lg text-[10px] font-bold uppercase transition-colors border border-white/20"
+ @click="
+ isSettingsOpen = true;
+ showOfflineHint = false;
+ "
+ >
+ Open Settings
+ </button>
+ <button
+ class="px-3 py-1 bg-black/10 hover:bg-black/20 rounded-lg text-[10px] font-bold uppercase transition-colors"
+ @click="showOfflineHint = false"
+ >
+ Dismiss
+ </button>
</div>
</div>
</div>
@@ -1036,6 +1291,7 @@ import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
+import * as mdi from "@mdi/js";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
@@ -1057,12 +1313,14 @@ import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import Toggle from "../forms/Toggle.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
+import MiniChat from "./MiniChat.vue";
export default {
name: "MapPage",
components: {
MaterialDesignIcon,
Toggle,
+ MiniChat,
},
data() {
return {
@@ -1082,7 +1340,10 @@ export default {
telemetryList: [],
markerSource: null,
markerLayer: null,
+ historySource: null,
+ historyLayer: null,
selectedMarker: null,
+ isMiniChatOpen: false,
queryMarker: null,
discoveredMarkers: [],
@@ -1125,6 +1386,8 @@ export default {
mbtilesList: [],
mbtilesDir: "",
isMapLoaded: false,
+ tileErrorCount: 0,
+ showOfflineHint: false,
// drawing tools
draw: null,
@@ -1159,6 +1422,7 @@ export default {
editingFeature: null,
noteText: "",
hoveredFeature: null,
+ hoveredMarker: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
@@ -1177,6 +1441,9 @@ export default {
};
},
computed: {
+ trackedPeers() {
+ return this.telemetryList.filter((t) => t.is_tracking);
+ },
estimatedTiles() {
if (!this.selectedBbox) return 0;
const [minLon, minLat, maxLon, maxLat] = this.selectedBbox;
@@ -1195,6 +1462,12 @@ export default {
},
},
watch: {
+ selectedMarker(newVal, oldVal) {
+ // Close mini-chat if the selected peer changed
+ if (!newVal || !oldVal || newVal.telemetry?.destination_hash !== oldVal.telemetry?.destination_hash) {
+ this.isMiniChatOpen = false;
+ }
+ },
showSaveDrawingModal(val) {
if (val) {
this.$nextTick(() => {
@@ -1273,6 +1546,7 @@ export default {
// add a temporary marker for the query target
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
+ originalCoord: fromLonLat([lon, lat]),
});
feature.setStyle(
this.createMarkerStyle({
@@ -1294,9 +1568,11 @@ export default {
if (this.map) {
this.map.on("moveend", () => {
const view = this.map.getView();
- this.currentCenter = toLonLat(view.getCenter());
- this.currentZoom = view.getZoom();
+ this.currentCenter =
+ view && typeof view.getCenter === "function" ? toLonLat(view.getCenter()) : this.currentCenter;
+ this.currentZoom = view && typeof view.getZoom === "function" ? view.getZoom() : this.currentZoom;
this.saveMapState();
+ this.updateMarkers();
});
}
@@ -1580,38 +1856,79 @@ export default {
// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
+ // setup history layer (trail)
+ this.historySource = new VectorSource();
+ this.historyLayer = new VectorLayer({
+ source: this.historySource,
+ style: new Style({
+ stroke: new Stroke({
+ color: "rgba(234, 179, 8, 0.6)", // yellow-500 light
+ width: 3,
+ lineDash: [10, 10], // dashed trail
+ }),
+ }),
+ zIndex: 40,
+ });
+ this.map.addLayer(this.historyLayer);
+
// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
style: (feature) => {
+ const isHovered = this.hoveredMarker === feature;
+ const scale = isHovered ? 2.0 : 1.6;
+ const zIndex = isHovered ? 1000 : 100;
+
const t = feature.get("telemetry");
const peer = feature.get("peer");
- const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
-
- // Calculate staleness
- const now = Date.now();
- const updatedAt = t.updated_at
- ? new Date(t.updated_at).getTime()
- : t.timestamp
- ? t.timestamp * 1000
- : now;
- const isStale = now - updatedAt > 10 * 60 * 1000;
+ const disc = feature.get("discovered");
+ let displayName = "";
+ let isStale = false;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
-
- if (peer?.lxmf_user_icon) {
- iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
- bgColor = peer.lxmf_user_icon.background_colour || bgColor;
+ let iconPath = null;
+
+ if (t) {
+ displayName = peer?.display_name || t.destination_hash.substring(0, 8);
+ // Calculate staleness
+ const now = Date.now();
+ const updatedAt = t.updated_at
+ ? new Date(t.updated_at).getTime()
+ : t.timestamp
+ ? t.timestamp * 1000
+ : now;
+ isStale = now - updatedAt > 10 * 60 * 1000;
+
+ if (peer?.lxmf_user_icon) {
+ iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
+ bgColor = peer.lxmf_user_icon.background_colour || bgColor;
+ if (peer.lxmf_user_icon.icon_name) {
+ iconPath = this.getMdiPath(peer.lxmf_user_icon.icon_name);
+ }
+ }
+ } else if (disc) {
+ displayName = disc.name;
+ iconColor = "#10b981"; // emerald-500
+ bgColor = "#d1fae5"; // emerald-100
+ iconPath = "M12 2L2 7L12 12L22 7L12 2Z M2 17L12 22L22 17 M2 12L12 17L22 12"; // router-wireless style path
+ } else if (feature === this.queryMarker) {
+ displayName = "Search Result";
+ iconColor = "#ef4444";
}
- return this.createMarkerStyle({
+ const style = this.createMarkerStyle({
iconColor,
bgColor,
label: displayName,
isStale,
+ iconPath,
+ scale,
+ isTracking: t ? t.is_tracking : false,
});
+ style.setZIndex(zIndex);
+ return style;
},
zIndex: 100,
});
@@ -1622,7 +1939,7 @@ export default {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
- if (feature && feature.get("telemetry")) {
+ if (feature && (feature.get("telemetry") || feature.get("discovered"))) {
this.onMarkerClick(feature);
} else {
this.selectedMarker = null;
@@ -1676,14 +1993,21 @@ export default {
return url.startsWith("/") || url.startsWith("./") || !url.startsWith("http");
}
},
- isDefaultOnlineUrl(url, type) {
+ isDefaultOnlineUrl(url) {
if (!url) return false;
- if (type === "tile") {
- return url.includes("tile.openstreetmap.org") || url.includes("openstreetmap.org");
- } else if (type === "nominatim") {
- return url.includes("nominatim.openstreetmap.org") || url.includes("openstreetmap.org");
- }
- return false;
+ const onlinePatterns = [
+ "openstreetmap.org",
+ "cartocdn.com",
+ "thunderforest.com",
+ "stamen.com",
+ "google.com",
+ "mapbox.com",
+ "arcgisonline.com",
+ "wmflabs.org",
+ "maptiler.com",
+ ];
+ const lowerUrl = url.toLowerCase();
+ return onlinePatterns.some((pattern) => lowerUrl.includes(pattern));
},
async checkApiConnection(url) {
if (!url || this.isLocalUrl(url)) {
@@ -1714,13 +2038,22 @@ export default {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
- const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl, "tile");
+ const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl);
let tileUrl;
if (isOffline) {
- if (isCustomLocal || (!isDefaultOnline && customTileUrl !== defaultTileUrl)) {
+ // If it's a known online URL, force offline tiles from MBTiles
+ if (isDefaultOnline) {
+ tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
+ } else if (isCustomLocal) {
+ // It's a local/mesh URL, allow it
+ tileUrl = customTileUrl;
+ } else if (customTileUrl !== defaultTileUrl) {
+ // It's a custom URL that isn't a known online one,
+ // assume it might be a local mesh server with a domain.
tileUrl = customTileUrl;
} else {
+ // Fallback to offline MBTiles
tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
}
} else {
@@ -1732,6 +2065,24 @@ export default {
crossOrigin: "anonymous",
});
+ // Track tile load errors to notify user if they appear to be offline
+ if (source && typeof source.on === "function") {
+ source.on("tileloaderror", () => {
+ if (!isOffline) {
+ this.tileErrorCount++;
+ if (this.tileErrorCount > 5) {
+ this.showOfflineHint = true;
+ // Reset count after showing hint to avoid multiple triggers
+ this.tileErrorCount = 0;
+ // Auto-hide hint after 30 seconds
+ setTimeout(() => {
+ this.showOfflineHint = false;
+ }, 30000);
+ }
+ }
+ });
+ }
+
const originalTileLoadFunction = source.getTileLoadFunction();
if (isOffline) {
@@ -1847,16 +2198,19 @@ export default {
return;
}
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
+
if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
- const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl, "tile");
+ const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl);
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;
const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
- const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
+ const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;
if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
@@ -1895,6 +2249,8 @@ export default {
},
async toggleCaching(enabled) {
this.cachingEnabled = enabled;
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
try {
await window.axios.patch("/api/v1/config", {
map_tile_cache_enabled: enabled,
@@ -1955,6 +2311,9 @@ export default {
if (this.exportStatus.status === "completed" || this.exportStatus.status === "failed") {
clearInterval(this.exportInterval);
this.isExporting = false;
+ if (this.exportStatus.status === "completed") {
+ this.loadMBTilesList();
+ }
}
} catch {
clearInterval(this.exportInterval);
@@ -2053,8 +2412,16 @@ export default {
}
},
setTileServer(type) {
+ this.tileErrorCount = 0;
+ this.showOfflineHint = false;
if (type === "osm") {
this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+ } else if (type === "carto-dark") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
+ } else if (type === "carto-voyager") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
+ } else if (type === "carto-light") {
+ this.tileServerUrl = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
}
this.saveTileServerUrl();
},
@@ -2171,7 +2538,7 @@ export default {
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
- const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl, "nominatim");
+ const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
@@ -2223,6 +2590,7 @@ export default {
console.error("Search error:", e);
if (e.message.includes("Failed to fetch") || e.message.includes("NetworkError")) {
this.searchError = this.$t("map.search_connection_error");
+ this.showOfflineHint = true;
} else {
this.searchError = this.$t("map.search_error") + ": " + e.message;
}
@@ -2641,9 +3009,25 @@ export default {
} else {
this.hoveredFeature = null;
}
+
+ // Handle marker hover effects
+ const isMarker = feature.get("telemetry") || feature.get("discovered");
+ if (isMarker && this.hoveredMarker !== feature) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = feature;
+ // Trigger style refresh
+ feature.changed();
+ if (oldHovered) oldHovered.changed();
+ }
+
this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
+ if (this.hoveredMarker) {
+ const oldHovered = this.hoveredMarker;
+ this.hoveredMarker = null;
+ oldHovered.changed();
+ }
this.map.getTargetElement().style.cursor = "";
}
},
@@ -2936,7 +3320,21 @@ export default {
},
goToMyLocation() {
- // Priority 1: Use telemetry data if available for our own hash
+ // Priority 1: Use manual location if configured
+ if (this.config?.location_source === "manual") {
+ const lat = parseFloat(this.config.location_manual_lat);
+ const lon = parseFloat(this.config.location_manual_lon);
+ if (!isNaN(lat) && !isNaN(lon)) {
+ this.map.getView().animate({
+ center: fromLonLat([lon, lat]),
+ zoom: 15,
+ duration: 1000,
+ });
+ return;
+ }
+ }
+
+ // Priority 2: Use telemetry data if available for our own hash
if (this.config && this.config.identity_hash) {
const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
if (myTelemetry && myTelemetry.telemetry?.location) {
@@ -3003,66 +3401,60 @@ export default {
geometry: new Point(coord),
telemetry: t,
peer: this.peers[t.destination_hash],
+ originalCoord: coord,
});
addFeatureToGroup(coord, feature);
}
// Process query marker
if (this.queryMarker) {
- const coord = this.queryMarker.getGeometry().getCoordinates();
+ const coord = this.queryMarker.get("originalCoord") || this.queryMarker.getGeometry().getCoordinates();
+ if (!this.queryMarker.get("originalCoord")) this.queryMarker.set("originalCoord", coord);
addFeatureToGroup(coord, this.queryMarker);
}
// Process discovered markers
if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
for (const feature of this.discoveredMarkers) {
- const coord = feature.getGeometry().getCoordinates();
+ const coord = feature.get("originalCoord") || feature.getGeometry().getCoordinates();
+ if (!feature.get("originalCoord")) feature.set("originalCoord", coord);
addFeatureToGroup(coord, feature);
}
}
- // Now handle groups (Marker Explosion)
+ // Now handle groups (Marker Clustering)
const view = this.map.getView();
- const resolution = view.getResolution();
- const offsetDist = resolution * 40; // 40 pixels offset
+ const resolution = view && typeof view.getResolution === "function" ? view.getResolution() : 1;
+ const offsetDist = resolution * 8; // Small 8 pixel offset to show they are separate
Object.entries(featuresByCoord).forEach(([coordStr, features]) => {
const trueCoord = coordStr.split(",").map(Number);
if (features.length === 1) {
- this.markerSource.addFeature(features[0]);
+ const feature = features[0];
+ const originalCoord = feature.get("originalCoord");
+ if (originalCoord) {
+ feature.setGeometry(new Point(originalCoord));
+ }
+ this.markerSource.addFeature(feature);
} else {
features.forEach((feature, index) => {
const angle = (index / features.length) * 2 * Math.PI;
+ const originalCoord = feature.get("originalCoord") || trueCoord;
const offsetCoord = [
- trueCoord[0] + Math.cos(angle) * offsetDist,
- trueCoord[1] + Math.sin(angle) * offsetDist,
+ originalCoord[0] + Math.cos(angle) * offsetDist,
+ originalCoord[1] + Math.sin(angle) * offsetDist,
];
// Move the marker to offset position
feature.setGeometry(new Point(offsetCoord));
this.markerSource.addFeature(feature);
-
- // Draw dashed line to true position
- const lineFeature = new Feature({
- geometry: new LineString([offsetCoord, trueCoord]),
- });
- lineFeature.setStyle(
- new Style({
- stroke: new Stroke({
- color: "rgba(59, 130, 246, 0.6)",
- width: 1.5,
- lineDash: [4, 4],
- }),
- })
- );
- this.markerSource.addFeature(lineFeature);
});
}
});
},
- createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
- const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
+ createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath, scale = 1.6, isTracking = false }) {
+ const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}-${scale}-${isTracking}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const markerFill = isStale ? "#d1d5db" : bgColor;
@@ -3071,19 +3463,37 @@ export default {
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
+ let svg = "";
+ if (isTracking) {
+ // Add a MeshChatX specific pulsing ring for tracking
+ svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
+ <circle cx="16" cy="16" r="10" fill="none" stroke="#3b82f6" stroke-width="2">
+ <animate attributeName="r" from="10" to="15" dur="1.5s" repeatCount="indefinite" />
+ <animate attributeName="stroke-opacity" from="1" to="0" dur="1.5s" repeatCount="indefinite" />
+ </circle>
+ <circle cx="16" cy="16" r="10" fill="#3b82f6" fill-opacity="0.2">
+ <animate attributeName="r" from="8" to="12" dur="1.5s" repeatCount="indefinite" />
+ </circle>
+ <g transform="translate(4,4)">
+ <path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/>
+ </g>
+ </svg>`;
+ } else {
+ svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
+ }
+
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
- scale: 1.6, // Reduced from 2.5
- imgSize: [24, 24],
+ scale: scale,
+ imgSize: isTracking ? [32, 32] : [24, 24],
}),
text: new Text({
text: label,
- offsetY: -45, // Adjusted from -60
+ offsetY: isTracking ? -35 - scale * 12 : -25 - scale * 12, // Dynamic offset based on scale
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
@@ -3097,18 +3507,67 @@ export default {
this.selectedMarker = {
telemetry: feature.get("telemetry"),
peer: feature.get("peer"),
+ discovered: feature.get("discovered"),
};
+
+ // draw path for telemetry markers
+ if (this.selectedMarker.telemetry) {
+ this.drawTelemetryPath(this.selectedMarker.telemetry.destination_hash);
+ } else {
+ this.clearTelemetryPath();
+ }
+ },
+ async drawTelemetryPath(hash) {
+ this.clearTelemetryPath();
+ try {
+ const response = await window.axios.get(`/api/v1/telemetry/history/${hash}?limit=50`);
+ const history = response.data.telemetry;
+ if (!history || history.length < 2) return;
+
+ // collect coordinates
+ const coords = [];
+ for (const entry of history) {
+ const loc = entry.telemetry?.location;
+ if (loc && loc.latitude !== undefined && loc.longitude !== undefined) {
+ coords.push(fromLonLat([loc.longitude, loc.latitude]));
+ }
+ }
+
+ if (coords.length < 2) return;
+
+ // create line feature
+ const line = new LineString(coords);
+ const feature = new Feature({
+ geometry: line,
+ type: "history_trail",
+ });
+
+ if (this.historySource) {
+ this.historySource.addFeature(feature);
+ }
+ } catch (e) {
+ console.error("Failed to draw telemetry path", e);
+ }
+ },
+ clearTelemetryPath() {
+ if (this.historySource) {
+ this.historySource.clear();
+ }
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxmf.telemetry") {
// Find and update or add to telemetryList
const index = this.telemetryList.findIndex((t) => t.destination_hash === json.destination_hash);
+ const oldEntry = index !== -1 ? this.telemetryList[index] : null;
const entry = {
destination_hash: json.destination_hash,
timestamp: json.timestamp,
telemetry: json.telemetry,
updated_at: new Date().toISOString(),
+ is_tracking:
+ json.is_tracking !== undefined ? json.is_tracking : oldEntry ? oldEntry.is_tracking : false,
+ physical_link: json.physical_link || oldEntry?.physical_link,
};
if (index !== -1) {
@@ -3116,18 +3575,69 @@ export default {
} else {
this.telemetryList.push(entry);
}
+
+ // Show notification for tracked peers
+ if (entry.telemetry?.location) {
+ const peer = this.peers[json.destination_hash];
+ const name = peer?.display_name || json.destination_hash.substring(0, 8);
+ const isTracked = this.telemetryList.find(
+ (t) => t.destination_hash === json.destination_hash
+ )?.is_tracking;
+
+ if (isTracked) {
+ ToastUtils.info(
+ `Live update: ${name} is at ${entry.telemetry.location.latitude.toFixed(4)}, ${entry.telemetry.location.longitude.toFixed(4)}`
+ );
+ }
+
+ // Update trail if this marker is currently selected
+ if (this.selectedMarker?.telemetry?.destination_hash === json.destination_hash) {
+ this.drawTelemetryPath(json.destination_hash);
+ }
+ }
+
this.updateMarkers();
}
},
formatTimestamp(ts) {
return new Date(ts * 1000).toLocaleString();
},
+ getMdiPath(iconName) {
+ if (!iconName) return null;
+ // same logic as MaterialDesignIcon.vue
+ const mdiName =
+ "mdi" +
+ iconName
+ .split("-")
+ .filter((word) => word.length > 0)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join("");
+ return mdi[mdiName] || null;
+ },
openChat(hash) {
this.$router.push({
name: "messages",
params: { destinationHash: hash },
});
},
+ async toggleTracking(hash) {
+ try {
+ const response = await window.axios.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
+ is_tracking: this.selectedMarker.telemetry.is_tracking ? false : true,
+ });
+ if (this.selectedMarker && this.selectedMarker.telemetry.destination_hash === hash) {
+ this.selectedMarker.telemetry.is_tracking = response.data.is_tracking;
+ }
+ // Also update in telemetryList
+ const t = this.telemetryList.find((t) => t.destination_hash === hash);
+ if (t) t.is_tracking = response.data.is_tracking;
+
+ ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
+ } catch (e) {
+ console.error("Failed to toggle tracking", e);
+ ToastUtils.error("Failed to update tracking status");
+ }
+ },
async mapDiscoveredNodes() {
try {
const response = await window.axios.get("/api/v1/reticulum/discovered-interfaces");
@@ -3149,6 +3659,7 @@ export default {
// Add markers
const feature = new Feature({
geometry: new Point(coord),
+ originalCoord: coord,
discovered: node,
});
feature.setStyle(

diff --git a/meshchatx/src/frontend/components/map/MiniChat.vue b/meshchatx/src/frontend/components/map/MiniChat.vue
new file mode 100644
index 00000000..65e88a49
--- /dev/null
+++ b/meshchatx/src/frontend/components/map/MiniChat.vue
@@ -0,0 +1,189 @@
+<template>
+ <div
+ class="flex flex-col h-64 bg-gray-50 dark:bg-zinc-950 rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-800"
+ >
+ <!-- message list -->
+ <div ref="messageList" class="flex-1 overflow-y-auto p-2 space-y-2 scrollbar-thin">
+ <div v-if="loading" class="flex justify-center py-4">
+ <v-icon icon="mdi-loading" class="animate-spin text-gray-400" size="20"></v-icon>
+ </div>
+ <div v-else-if="messages.length === 0" class="text-center py-4 text-xs text-gray-400">No messages yet</div>
+ <div
+ v-for="msg in messages"
+ :key="msg.hash"
+ class="flex flex-col max-w-[90%]"
+ :class="msg.is_outbound ? 'ml-auto items-end' : 'mr-auto items-start'"
+ >
+ <div
+ class="px-2 py-1 rounded-lg text-xs break-words shadow-sm"
+ :class="
+ msg.is_outbound
+ ? 'bg-blue-600 text-white'
+ : 'bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200'
+ "
+ >
+ <!-- Telemetry Header if no content -->
+ <div
+ v-if="!msg.content && msg.fields?.telemetry"
+ class="flex items-center gap-1 mb-1 pb-1 border-b border-white/10 opacity-80"
+ >
+ <v-icon icon="mdi-satellite-variant" size="10"></v-icon>
+ <span class="text-[8px] font-bold uppercase tracking-wider"
+ >{{ msg.is_outbound ? "Sent" : "Received" }} Telemetry</span
+ >
+ </div>
+
+ <div
+ v-if="!msg.content && msg.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])"
+ class="flex items-center gap-1 mb-1 pb-1 border-b border-white/10 opacity-80"
+ >
+ <v-icon icon="mdi-crosshairs-question" size="10"></v-icon>
+ <span class="text-[8px] font-bold uppercase tracking-wider">Location Request</span>
+ </div>
+
+ <div v-if="msg.content" class="leading-normal">{{ msg.content }}</div>
+
+ <!-- Mini Telemetry Data -->
+ <div v-if="msg.fields?.telemetry" class="mt-1 space-y-1">
+ <div
+ v-if="msg.fields.telemetry.location"
+ class="flex items-center gap-1 text-[9px] font-mono opacity-90"
+ >
+ <v-icon icon="mdi-map-marker" size="10"></v-icon>
+ <span
+ >{{ msg.fields.telemetry.location.latitude.toFixed(4) }},
+ {{ msg.fields.telemetry.location.longitude.toFixed(4) }}</span
+ >
+ </div>
+ <div class="flex gap-2 opacity-70 text-[8px]">
+ <span v-if="msg.fields.telemetry.battery" class="flex items-center gap-0.5">
+ <v-icon icon="mdi-battery" size="8"></v-icon
+ >{{ msg.fields.telemetry.battery.charge_percent }}%
+ </span>
+ <span v-if="msg.fields.telemetry.physical_link" class="flex items-center gap-0.5">
+ <v-icon icon="mdi-antenna" size="8"></v-icon>SNR:
+ {{ msg.fields.telemetry.physical_link.snr }}dB
+ </span>
+ </div>
+ </div>
+ </div>
+ <div class="text-[8px] text-gray-400 mt-0.5">
+ {{ formatTime(msg.timestamp) }}
+ </div>
+ </div>
+ </div>
+
+ <!-- input -->
+ <div class="p-2 bg-white dark:bg-zinc-900 border-t border-gray-200 dark:border-zinc-800">
+ <div class="flex gap-1">
+ <input
+ v-model="newMessage"
+ type="text"
+ class="flex-1 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 text-gray-900 dark:text-zinc-100"
+ placeholder="Type a message..."
+ @keydown.enter="sendMessage"
+ />
+ <button
+ :disabled="!newMessage.trim() || sending"
+ class="p-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 dark:disabled:bg-zinc-700 text-white rounded-md transition-colors"
+ @click="sendMessage"
+ >
+ <v-icon
+ :icon="sending ? 'mdi-loading' : 'mdi-send'"
+ :class="{ 'animate-spin': sending }"
+ size="14"
+ ></v-icon>
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "MiniChat",
+ props: {
+ destinationHash: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ messages: [],
+ newMessage: "",
+ loading: false,
+ sending: false,
+ };
+ },
+ watch: {
+ destinationHash: {
+ immediate: true,
+ handler() {
+ this.fetchMessages();
+ },
+ },
+ },
+ mounted() {
+ // Listen for new messages via websocket if possible
+ // For now we'll just poll or rely on parent updates if needed
+ },
+ methods: {
+ async fetchMessages() {
+ if (!this.destinationHash) return;
+ this.loading = true;
+ try {
+ const response = await window.axios.get(
+ `/api/v1/lxmf-messages/conversation/${this.destinationHash}?count=20&order=desc`
+ );
+ this.messages = (response.data.lxmf_messages || []).reverse();
+ this.scrollToBottom();
+ } catch (e) {
+ console.error("Failed to fetch messages", e);
+ } finally {
+ this.loading = false;
+ }
+ },
+ async sendMessage() {
+ if (!this.newMessage.trim() || this.sending) return;
+ this.sending = true;
+ try {
+ const response = await window.axios.post("/api/v1/lxmf-messages/send", {
+ lxmf_message: {
+ destination_hash: this.destinationHash,
+ content: this.newMessage,
+ },
+ });
+
+ // Add message to list locally for immediate feedback
+ const msg = response.data.lxmf_message;
+ this.messages.push({
+ hash: msg.hash,
+ content: msg.content,
+ is_outbound: true,
+ timestamp: msg.created_at,
+ });
+ this.newMessage = "";
+ this.scrollToBottom();
+ } catch (e) {
+ console.error("Failed to send message", e);
+ } finally {
+ this.sending = false;
+ }
+ },
+ scrollToBottom() {
+ this.$nextTick(() => {
+ if (this.$refs.messageList) {
+ this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
+ }
+ });
+ },
+ formatTime(ts) {
+ if (!ts) return "";
+ const date = new Date(ts * 1000);
+ if (isNaN(date.getTime())) return "";
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
index 3eb86605..6a8418e1 100644
--- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue
@@ -2,7 +2,7 @@
<DropDownMenu>
<template #button>
<IconButton>
- <MaterialDesignIcon icon-name="dots-vertical" class="size-6" />
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-7" />
</IconButton>
</template>
<template #items>
@@ -43,6 +43,22 @@
<span class="text-red-500">Delete Message History</span>
</DropDownMenuItem>
</div>
+
+ <!-- telemetry trust toggle -->
+ <div v-if="GlobalState.config.telemetry_enabled" class="border-t">
+ <DropDownMenuItem @click="onToggleTelemetryTrust">
+ <MaterialDesignIcon
+ :icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
+ :class="contact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-500'"
+ class="size-5"
+ />
+ <span>{{
+ contact?.is_telemetry_trusted
+ ? $t("app.telemetry_trust_revoke")
+ : $t("app.telemetry_trust_grant")
+ }}</span>
+ </DropDownMenuItem>
+ </div>
</template>
</DropDownMenu>
</template>
@@ -70,7 +86,18 @@ export default {
required: true,
},
},
- emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed", "popout"],
+ emits: [
+ "conversation-deleted",
+ "set-custom-display-name",
+ "block-status-changed",
+ "popout",
+ "view-telemetry-history",
+ ],
+ data() {
+ return {
+ contact: null,
+ };
+ },
computed: {
isBlocked() {
if (!this.peer) {
@@ -79,7 +106,72 @@ export default {
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
},
},
+ watch: {
+ peer: {
+ immediate: true,
+ handler() {
+ this.fetchContact();
+ },
+ },
+ },
+ mounted() {
+ GlobalEmitter.on("contact-updated", this.onContactUpdated);
+ },
+ unmounted() {
+ GlobalEmitter.off("contact-updated", this.onContactUpdated);
+ },
methods: {
+ onContactUpdated(data) {
+ if (this.peer?.destination_hash === data.remote_identity_hash) {
+ this.fetchContact();
+ }
+ },
+ async fetchContact() {
+ if (!this.peer || !this.peer.destination_hash) return;
+ try {
+ const response = await window.axios.get(
+ `/api/v1/telephone/contacts/check/${this.peer.destination_hash}`
+ );
+ if (response.data.is_contact) {
+ this.contact = response.data.contact;
+ } else {
+ this.contact = null;
+ }
+ } catch (e) {
+ console.error("Failed to fetch contact", e);
+ }
+ },
+ async onToggleTelemetryTrust() {
+ const newStatus = !this.contact?.is_telemetry_trusted;
+ try {
+ if (!this.contact) {
+ // create contact first
+ await window.axios.post("/api/v1/telephone/contacts", {
+ name: this.peer.display_name,
+ remote_identity_hash: this.peer.destination_hash,
+ is_telemetry_trusted: true,
+ });
+ await this.fetchContact();
+ } else {
+ await window.axios.patch(`/api/v1/telephone/contacts/${this.contact.id}`, {
+ is_telemetry_trusted: newStatus,
+ });
+ this.contact.is_telemetry_trusted = newStatus;
+ }
+ GlobalEmitter.emit("contact-updated", {
+ remote_identity_hash: this.peer.destination_hash,
+ is_telemetry_trusted: newStatus,
+ });
+ DialogUtils.alert(
+ newStatus
+ ? this.$t("app.telemetry_trust_granted_alert")
+ : this.$t("app.telemetry_trust_revoked_alert")
+ );
+ } catch (e) {
+ DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
+ console.error(e);
+ }
+ },
async onBlockDestination() {
if (
!(await DialogUtils.confirm(

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index fd09aafe..69118745 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -57,24 +57,32 @@
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
</div>
</div>
- <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
<!-- destination hash -->
- <div class="inline-block mr-1">
- <div
- class="cursor-pointer hover:text-blue-500 transition-colors"
- :title="selectedPeer.destination_hash"
- @click="copyHash(selectedPeer.destination_hash)"
- >
- {{ formatDestinationHash(selectedPeer.destination_hash) }}
- </div>
+ <div
+ class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
+ :title="selectedPeer.destination_hash"
+ @click="copyHash(selectedPeer.destination_hash)"
+ >
+ {{ formatDestinationHash(selectedPeer.destination_hash) }}
</div>
- <div class="inline-block">
- <div class="flex space-x-1">
+ <div
+ v-if="
+ selectedPeerPath ||
+ selectedPeerSignalMetrics?.snr != null ||
+ selectedPeerLxmfStampInfo?.stamp_cost
+ "
+ class="flex items-center gap-2 min-w-0"
+ >
+ <span class="text-gray-300 dark:text-zinc-700 shrink-0">•</span>
+
+ <div class="flex items-center gap-2 truncate">
<!-- hops away -->
<span
v-if="selectedPeerPath"
- class="flex my-auto cursor-pointer"
+ class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
+ title="Path information"
@click="onDestinationPathClick(selectedPeerPath)"
>
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
@@ -84,19 +92,30 @@
</span>
<!-- snr -->
- <span v-if="selectedPeerSignalMetrics?.snr != null" class="flex my-auto space-x-1">
- <span v-if="selectedPeerPath">•</span>
- <span class="cursor-pointer" @click="onSignalMetricsClick(selectedPeerSignalMetrics)">{{
- $t("messages.snr", { snr: selectedPeerSignalMetrics.snr })
- }}</span>
+ <span
+ v-if="selectedPeerSignalMetrics?.snr != null"
+ class="flex items-center gap-2 shrink-0"
+ >
+ <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
+ <span
+ class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
+ title="Signal quality"
+ @click="onSignalMetricsClick(selectedPeerSignalMetrics)"
+ >{{ $t("messages.snr", { snr: selectedPeerSignalMetrics.snr }) }}</span
+ >
</span>
<!-- stamp cost -->
- <span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex my-auto space-x-1">
- <span v-if="selectedPeerPath || selectedPeerSignalMetrics?.snr != null">•</span>
- <span class="cursor-pointer" @click="onStampInfoClick(selectedPeerLxmfStampInfo)">{{
- $t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
- }}</span>
+ <span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
+ <span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
+ <span
+ class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
+ title="LXMF stamp requirement"
+ @click="onStampInfoClick(selectedPeerLxmfStampInfo)"
+ >{{
+ $t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
+ }}</span
+ >
</span>
</div>
</div>
@@ -104,7 +123,7 @@
</div>
<!-- dropdown menu -->
- <div class="ml-auto flex items-center gap-1">
+ <div class="ml-auto flex items-center gap-1.5">
<!-- retry all failed messages -->
<IconButton
v-if="hasFailedOrCancelledMessages"
@@ -112,34 +131,177 @@
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="retryAllFailedOrCancelledMessages"
>
- <MaterialDesignIcon icon-name="refresh" class="size-6" />
+ <MaterialDesignIcon icon-name="refresh" class="size-7" />
</IconButton>
- <ConversationDropDownMenu
- v-if="selectedPeer"
- :peer="selectedPeer"
- @conversation-deleted="onConversationDeleted"
- @set-custom-display-name="updateCustomDisplayName"
- @popout="openConversationPopout"
- />
+ <!-- telemetry history button -->
+ <IconButton title="View Telemetry History" @click="isTelemetryHistoryModalOpen = true">
+ <MaterialDesignIcon icon-name="satellite-variant" class="size-7" />
+ </IconButton>
<!-- call button -->
<IconButton title="Start a Call" @click="onStartCall">
- <MaterialDesignIcon icon-name="phone" class="size-6" />
+ <MaterialDesignIcon icon-name="phone" class="size-7" />
</IconButton>
<!-- share contact button -->
<IconButton title="Share Contact" @click="openShareContactModal">
- <MaterialDesignIcon icon-name="notebook-outline" class="size-6" />
+ <MaterialDesignIcon icon-name="notebook-outline" class="size-7" />
</IconButton>
+ <ConversationDropDownMenu
+ v-if="selectedPeer"
+ :peer="selectedPeer"
+ @conversation-deleted="onConversationDeleted"
+ @set-custom-display-name="updateCustomDisplayName"
+ @popout="openConversationPopout"
+ />
+
<!-- close button -->
<IconButton title="Close" @click="close">
- <MaterialDesignIcon icon-name="close" class="size-6" />
+ <MaterialDesignIcon icon-name="close" class="size-7" />
</IconButton>
</div>
</div>
+ <!-- Telemetry History Modal -->
+ <div
+ v-if="isTelemetryHistoryModalOpen"
+ class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
+ @click.self="isTelemetryHistoryModalOpen = false"
+ >
+ <div
+ class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
+ >
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500" />
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">Telemetry History</h3>
+ </div>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
+ @click="isTelemetryHistoryModalOpen = false"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+ <div class="flex-1 overflow-y-auto p-4 space-y-3">
+ <div v-if="selectedPeerTelemetryItems.length === 0" class="text-center py-8 text-gray-400">
+ No telemetry history found for this peer.
+ </div>
+ <div
+ v-for="item in selectedPeerTelemetryItems"
+ :key="item.lxmf_message.hash"
+ class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30"
+ >
+ <div class="flex justify-between items-start mb-2">
+ <span
+ class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
+ >
+ {{ item.is_outbound ? "Sent" : "Received" }}
+ </span>
+ <span class="text-[10px] text-gray-400">{{
+ formatTimeAgo(item.lxmf_message.created_at)
+ }}</span>
+ </div>
+
+ <!-- location -->
+ <div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
+ <button
+ type="button"
+ class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
+ @click="viewLocationOnMap(item.lxmf_message.fields.telemetry.location)"
+ >
+ <MaterialDesignIcon icon-name="map-marker" class="size-4" />
+ {{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
+ {{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
+ </button>
+ </div>
+
+ <!-- sensors -->
+ <div
+ v-if="item.lxmf_message.fields?.telemetry"
+ class="flex flex-wrap gap-3 text-[10px] text-gray-500"
+ >
+ <span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
+ <MaterialDesignIcon icon-name="battery" class="size-3" />
+ Battery: {{ item.lxmf_message.fields.telemetry.battery.charge_percent }}%
+ </span>
+ <span
+ v-if="item.lxmf_message.fields.telemetry.physical_link"
+ class="flex items-center gap-1"
+ >
+ <MaterialDesignIcon icon-name="antenna" class="size-3" />
+ SNR: {{ item.lxmf_message.fields.telemetry.physical_link.snr }}dB
+ </span>
+ </div>
+
+ <!-- commands -->
+ <div
+ v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
+ class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
+ >
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
+ <span>Location Request</span>
+ </div>
+ </div>
+ </div>
+ <div
+ class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 bg-gray-50/30 dark:bg-zinc-900/20 flex flex-col gap-4"
+ >
+ <div v-if="telemetryBatteryHistory.length > 1" class="space-y-2">
+ <div class="flex items-center justify-between">
+ <span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
+ >Battery Level (%)</span
+ >
+ <span class="text-[10px] text-gray-500"
+ >{{ telemetryBatteryHistory[0].y }}% →
+ {{ telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y }}%</span
+ >
+ </div>
+ <svg class="w-full h-12 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
+ <path
+ :d="batterySparklinePath"
+ fill="none"
+ stroke="#3b82f6"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ <circle
+ :cx="100"
+ :cy="100 - telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y"
+ r="3"
+ fill="#3b82f6"
+ />
+ </svg>
+ </div>
+
+ <div class="flex justify-between items-center w-full">
+ <label class="flex items-center gap-2 cursor-pointer group">
+ <input
+ v-model="showTelemetryInChat"
+ type="checkbox"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+ <span
+ class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
+ >Show telemetry in main chat</span
+ >
+ </label>
+ <button
+ type="button"
+ class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
+ @click="isTelemetryHistoryModalOpen = false"
+ >
+ Done
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- Share Contact Modal -->
<div
v-if="isShareContactModalOpen"
@@ -264,15 +426,54 @@
</div>
<!-- content -->
+ <!-- eslint-disable vue/no-v-html -->
<div
v-if="chatItem.lxmf_message.content && !getParsedItems(chatItem)?.isOnlyPaperMessage"
- class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
+ class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
:style="{
'font-family': 'inherit',
'font-size': (config?.message_font_size || 14) + 'px',
}"
+ @click="handleMessageClick"
+ v-html="renderMarkdown(chatItem.lxmf_message.content)"
+ ></div>
+ <!-- eslint-enable vue/no-v-html -->
+
+ <!-- telemetry placeholder for empty content messages -->
+ <div
+ v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry"
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
+ >
+ <MaterialDesignIcon icon-name="satellite-variant" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
+ {{ chatItem.is_outbound ? "Telemetry update sent" : "Telemetry update received" }}
+ </span>
+ </div>
+
+ <div
+ v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry_stream"
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
+ >
+ <MaterialDesignIcon icon-name="database-sync" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60"
+ >Telemetry stream received ({{
+ chatItem.lxmf_message.fields.telemetry_stream.length
+ }}
+ entries)</span
+ >
+ </div>
+
+ <div
+ v-if="
+ !chatItem.lxmf_message.content &&
+ chatItem.lxmf_message.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])
+ "
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
- {{ chatItem.lxmf_message.content }}
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
+ {{ chatItem.is_outbound ? "Location Request Sent" : "Location Request Received" }}
+ </span>
</div>
<!-- parsed items (contacts / paper messages) -->
@@ -481,29 +682,120 @@
</a>
</div>
+ <!-- commands -->
+ <div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
+ <div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
+ <div
+ v-if="command['0x01'] || command['1'] || command['0x1']"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="
+ chatItem.is_outbound
+ ? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ "
+ >
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
+ <div class="text-left">
+ <div class="font-bold text-xs uppercase tracking-wider opacity-80">
+ {{ $t("messages.location_requested") }}
+ </div>
+ <div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
+ Peer is requesting your location
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- telemetry / location field -->
- <div v-if="chatItem.lxmf_message.fields?.telemetry?.location" class="pb-1 mt-1">
- <button
- type="button"
- class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
- :class="
- chatItem.is_outbound
- ? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
- : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
- "
- @click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
- >
- <MaterialDesignIcon icon-name="map-marker" class="size-5" />
- <div class="text-left">
- <div class="font-bold text-xs uppercase tracking-wider opacity-80">
- Location
+ <div v-if="chatItem.lxmf_message.fields?.telemetry" class="pb-1 mt-1 space-y-2">
+ <div class="flex flex-wrap gap-2">
+ <button
+ v-if="chatItem.lxmf_message.fields.telemetry.location"
+ type="button"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="
+ chatItem.is_outbound
+ ? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ "
+ @click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
+ >
+ <MaterialDesignIcon icon-name="map-marker" class="size-5" />
+ <div class="text-left">
+ <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
+ Location
+ </div>
+ <div class="text-[9px] font-mono opacity-70">
+ {{
+ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6)
+ }},
+ {{
+ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6)
+ }}
+ </div>
</div>
- <div class="text-[10px] font-mono opacity-70">
- {{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
- {{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
+ </button>
+
+ <!-- Live Track Toggle Button (only for incoming) -->
+ <button
+ v-if="!chatItem.is_outbound"
+ type="button"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="[
+ selectedPeer?.is_tracking
+ ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30 shadow-inner'
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
+ ]"
+ @click="toggleTracking()"
+ >
+ <MaterialDesignIcon
+ :icon-name="selectedPeer?.is_tracking ? 'radar' : 'crosshairs'"
+ class="size-5"
+ :class="{ 'animate-pulse text-blue-500': selectedPeer?.is_tracking }"
+ />
+ <div class="text-left">
+ <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
+ {{ selectedPeer?.is_tracking ? "Tracking Active" : "Live Track" }}
+ </div>
+ <div class="text-[9px] opacity-70">
+ {{
+ selectedPeer?.is_tracking
+ ? "Auto-requesting location"
+ : "Enable live tracking"
+ }}
+ </div>
</div>
+ </button>
+ </div>
+
+ <!-- other sensor data if available -->
+ <div
+ v-if="
+ chatItem.lxmf_message.fields.telemetry.battery ||
+ chatItem.lxmf_message.fields.telemetry.physical_link
+ "
+ class="flex gap-3 px-1"
+ >
+ <div
+ v-if="chatItem.lxmf_message.fields.telemetry.battery"
+ class="flex items-center gap-1 opacity-60 text-[10px]"
+ >
+ <MaterialDesignIcon icon-name="battery" class="size-3" />
+ <span
+ >{{ chatItem.lxmf_message.fields.telemetry.battery.charge_percent }}%</span
+ >
</div>
- </button>
+ <div
+ v-if="chatItem.lxmf_message.fields.telemetry.physical_link"
+ class="flex items-center gap-1 opacity-60 text-[10px]"
+ >
+ <MaterialDesignIcon icon-name="antenna" class="size-3" />
+ <span
+ >SNR: {{ chatItem.lxmf_message.fields.telemetry.physical_link.snr }}dB</span
+ >
+ </div>
+ </div>
</div>
</div>
@@ -1395,6 +1687,7 @@ import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
+import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "ConversationViewer",
@@ -1428,7 +1721,7 @@ export default {
required: true,
},
},
- emits: ["close", "reload-conversations", "update:selectedPeer"],
+ emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
data() {
return {
GlobalState,
@@ -1493,6 +1786,9 @@ export default {
translatorLanguages: [],
propagationNodeStatus: null,
propagationStatusInterval: null,
+
+ showTelemetryInChat: false,
+ isTelemetryHistoryModalOpen: false,
};
},
computed: {
@@ -1605,7 +1901,15 @@ export default {
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
const isToSelectedPeer =
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
- return isFromSelectedPeer || isToSelectedPeer;
+
+ if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
+
+ // filter telemetry if disabled
+ if (!this.showTelemetryInChat && this.isTelemetryOnly(chatItem.lxmf_message)) {
+ return false;
+ }
+
+ return true;
}
return false;
@@ -1615,6 +1919,24 @@ export default {
// no peer, so no chat items!
return [];
},
+ selectedPeerTelemetryItems() {
+ if (!this.selectedPeer) return [];
+ return this.chatItems
+ .filter((chatItem) => {
+ if (chatItem.type === "lxmf_message") {
+ const isFromSelectedPeer =
+ chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
+ const isToSelectedPeer =
+ chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
+
+ if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
+
+ return this.isTelemetryOnly(chatItem.lxmf_message);
+ }
+ return false;
+ })
+ .reverse();
+ },
selectedPeerChatItemsReversed() {
// ensure a copy of the array is returned in reverse order
return this.selectedPeerChatItems.map((message) => message).reverse();
@@ -1631,6 +1953,31 @@ export default {
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
);
},
+ telemetryBatteryHistory() {
+ return this.selectedPeerTelemetryItems
+ .filter((item) => item.lxmf_message.fields?.telemetry?.battery)
+ .map((item) => ({
+ x: item.lxmf_message.timestamp,
+ y: item.lxmf_message.fields.telemetry.battery.charge_percent,
+ }))
+ .sort((a, b) => a.x - b.x);
+ },
+ batterySparklinePath() {
+ const history = this.telemetryBatteryHistory;
+ if (history.length < 2) return "";
+
+ const minX = history[0].x;
+ const maxX = history[history.length - 1].x;
+ const rangeX = maxX - minX || 1;
+
+ return history
+ .map((p, i) => {
+ const x = ((p.x - minX) / rangeX) * 100;
+ const y = 100 - p.y; // SVG y is top-down
+ return `${i === 0 ? "M" : "L"} ${x} ${y}`;
+ })
+ .join(" ");
+ },
},
watch: {
selectedPeer: {
@@ -1695,6 +2042,26 @@ export default {
}, 2000);
},
methods: {
+ renderMarkdown(text) {
+ return MarkdownRenderer.render(text);
+ },
+ handleMessageClick(event) {
+ const nomadnetLink = event.target.closest(".nomadnet-link");
+ if (nomadnetLink) {
+ event.preventDefault();
+ const url = nomadnetLink.getAttribute("data-nomadnet-url");
+ if (url) {
+ const [hash, ...pathParts] = url.split(":");
+ const path = pathParts.join(":");
+ const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
+ this.$router.push({
+ name: routeName,
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ }
+ }
+ },
async updatePropagationNodeStatus() {
try {
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
@@ -2048,13 +2415,7 @@ export default {
break;
}
case "lxm.ingest_uri.result": {
- if (json.status === "success") {
- ToastUtils.success(json.message);
- } else if (json.status === "error") {
- ToastUtils.error(json.message);
- } else {
- ToastUtils.warning(json.message);
- }
+ // Handled in App.vue or MessagesPage.vue
break;
}
}
@@ -2661,6 +3022,7 @@ export default {
fileAttachmentsTotalSize += file.size;
fileAttachments.push({
file_name: file.name,
+ file_size: file.size,
file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()),
});
}
@@ -2675,6 +3037,7 @@ export default {
imageTotalSize += image.size;
images.push({
image_type: image.type.replace("image/", ""),
+ image_size: image.size,
image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
name: image.name,
});
@@ -2687,6 +3050,7 @@ export default {
audioTotalSize = this.newMessageAudio.size;
fields["audio"] = {
audio_mode: this.newMessageAudio.audio_mode,
+ audio_size: this.newMessageAudio.size,
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
};
}
@@ -2882,27 +3246,59 @@ export default {
}
},
async shareLocation() {
+ const toastKey = "location_share";
try {
+ if (this.config?.location_source === "manual") {
+ const lat = parseFloat(this.config.location_manual_lat);
+ const lon = parseFloat(this.config.location_manual_lon);
+ const alt = parseFloat(this.config.location_manual_alt);
+
+ if (isNaN(lat) || isNaN(lon)) {
+ ToastUtils.error("Invalid manual coordinates in settings", 5000, toastKey);
+ return;
+ }
+
+ this.newMessageTelemetry = {
+ latitude: lat,
+ longitude: lon,
+ altitude: isNaN(alt) ? 0 : alt,
+ speed: 0,
+ bearing: 0,
+ accuracy: 0,
+ last_update: Math.floor(Date.now() / 1000),
+ };
+ this.sendMessage();
+ ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
+ return;
+ }
+
if (!navigator.geolocation) {
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
return;
}
+ ToastUtils.loading(this.$t("messages.fetching_location"), 0, toastKey);
+
navigator.geolocation.getCurrentPosition(
(position) => {
this.newMessageTelemetry = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude || 0,
- speed: (position.coords.speed || 0) * 3.6, // m/s to km/h to match Sideband
+ speed: (position.coords.speed || 0) * 3.6, // m/s to km/h
bearing: position.coords.heading || 0,
accuracy: position.coords.accuracy || 0,
last_update: Math.floor(Date.now() / 1000),
};
this.sendMessage();
+ ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
},
(error) => {
- DialogUtils.alert(`Failed to get location: ${error.message}`);
+ ToastUtils.error(
+ `Failed to get location: ${error.message}. Try setting location manually in Settings.`,
+ 5000,
+ toastKey
+ );
},
{
enableHighAccuracy: true,
@@ -2912,6 +3308,7 @@ export default {
);
} catch (e) {
console.log(e);
+ ToastUtils.error(`Error: ${e.message}`, 5000, toastKey);
}
},
async requestLocation() {
@@ -2924,9 +3321,7 @@ export default {
destination_hash: this.selectedPeer.destination_hash,
content: "",
fields: {
- commands: [
- { "0x01": Math.floor(Date.now() / 1000) }, // Sideband TELEMETRY_REQUEST
- ],
+ commands: [{ "0x01": Math.floor(Date.now() / 1000) }],
},
},
});
@@ -2948,6 +3343,32 @@ export default {
},
});
},
+ isTelemetryOnly(msg) {
+ const hasContent = msg.content && msg.content.trim() !== "";
+ const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
+ const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
+ const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
+
+ return !hasContent && !hasAttachments && (hasTelemetry || hasCommands);
+ },
+ async toggleTracking() {
+ if (!this.selectedPeer) return;
+ const hash = this.selectedPeer.destination_hash;
+ try {
+ const response = await window.axios.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
+ is_tracking: !this.selectedPeer.is_tracking,
+ });
+ // Emit event to parent to update peer status
+ this.$emit("update-peer-tracking", {
+ destination_hash: hash,
+ is_tracking: response.data.is_tracking,
+ });
+ ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
+ } catch (e) {
+ console.error("Failed to toggle tracking", e);
+ ToastUtils.error("Failed to update tracking status");
+ }
+ },
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
@@ -3387,4 +3808,29 @@ export default {
.dark .audio-controls-dark {
filter: invert(1) hue-rotate(180deg);
}
+.markdown-content :deep(p) {
+ margin: 0.5rem 0;
+}
+
+.markdown-content :deep(strong) {
+ font-weight: 700;
+}
+
+.markdown-content :deep(em) {
+ font-style: italic;
+}
+
+.markdown-content :deep(pre) {
+ margin: 0.75rem 0;
+}
+
+.markdown-content :deep(code) {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+
+.markdown-content :deep(h1),
+.markdown-content :deep(h2),
+.markdown-content :deep(h3) {
+ line-height: 1.2;
+}
</style>

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 466dcfd2..954a0381 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -50,6 +50,7 @@
:selected-peer="selectedPeer"
:conversations="conversations"
@update:selected-peer="onPeerClick"
+ @update-peer-tracking="onUpdatePeerTracking"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"
/>
@@ -293,16 +294,21 @@ export default {
await this.getConversations();
break;
}
+ case "lxmf.telemetry": {
+ // update tracking status if peer matches
+ const destHash = json.destination_hash;
+ if (this.peers[destHash]) {
+ this.peers[destHash].is_tracking = json.is_tracking;
+ }
+ if (this.selectedPeer && this.selectedPeer.destination_hash === destHash) {
+ this.selectedPeer.is_tracking = json.is_tracking;
+ }
+ break;
+ }
case "lxm.ingest_uri.result": {
if (json.status === "success") {
- ToastUtils.success(json.message);
+ this.ingestUri = "";
await this.getConversations();
- } else if (json.status === "error") {
- ToastUtils.error(json.message);
- } else if (json.status === "warning") {
- ToastUtils.warning(json.message);
- } else {
- ToastUtils.info(json.message);
}
break;
}
@@ -398,6 +404,7 @@ export default {
contact_image: conversation.contact_image ?? existingPeer.contact_image,
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
updated_at: conversation.updated_at ?? existingPeer.updated_at,
+ is_tracking: conversation.is_tracking ?? existingPeer.is_tracking,
};
}
@@ -561,6 +568,14 @@ export default {
const existing = this.peers[announce.destination_hash] || {};
this.peers[announce.destination_hash] = { ...existing, ...announce };
},
+ onUpdatePeerTracking({ destination_hash, is_tracking }) {
+ if (this.peers[destination_hash]) {
+ this.peers[destination_hash].is_tracking = is_tracking;
+ }
+ if (this.selectedPeer && this.selectedPeer.destination_hash === destination_hash) {
+ this.selectedPeer.is_tracking = is_tracking;
+ }
+ },
onPeerClick: function (peer) {
// update selected peer
this.selectedPeer = peer;

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 96f26de9..c1699be6 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -390,27 +390,37 @@
</div>
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
{{
- conversation.latest_message_preview ??
- conversation.latest_message_title ??
- "No messages yet"
+ stripMarkdown(
+ conversation.latest_message_preview ?? conversation.latest_message_title
+ ) ?? "No messages yet"
}}
</div>
</div>
- <div class="flex items-center space-x-1">
- <div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
- <MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
+ <div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
+ <div class="flex items-center space-x-1">
+ <div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
+ <MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
+ </div>
+ <div
+ v-if="
+ conversation.is_unread &&
+ conversation.destination_hash !== selectedDestinationHash
+ "
+ class="my-auto ml-1"
+ >
+ <div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
+ </div>
+ <div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
+ <div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
+ </div>
</div>
- <div
- v-if="
- conversation.is_unread && conversation.destination_hash !== selectedDestinationHash
- "
- class="my-auto ml-1"
+ <button
+ type="button"
+ class="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-zinc-800 rounded-lg transition-all"
+ @click.stop="onRightClick($event, conversation.destination_hash)"
>
- <div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
- </div>
- <div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
- <div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
- </div>
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
+ </button>
</div>
</div>
@@ -430,6 +440,31 @@
<span class="font-medium">Mark as Read</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <button
+ v-if="GlobalState.config.telemetry_enabled"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="toggleTelemetryTrust(contextMenu.targetHash)"
+ >
+ <MaterialDesignIcon
+ :icon-name="
+ contextMenu.targetContact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'
+ "
+ :class="
+ contextMenu.targetContact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-400'
+ "
+ class="size-4"
+ />
+ <span class="font-medium">{{
+ contextMenu.targetContact?.is_telemetry_trusted
+ ? "Revoke Telemetry Trust"
+ : "Trust for Telemetry"
+ }}</span>
+ </button>
+ <div
+ v-if="GlobalState.config.telemetry_enabled"
+ class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
+ ></div>
<div
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
>
@@ -634,6 +669,8 @@ import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
+import GlobalEmitter from "../../js/GlobalEmitter";
+import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "MessagesSidebar",
@@ -749,6 +786,7 @@ export default {
x: 0,
y: 0,
targetHash: null,
+ targetContact: null,
},
draggedHash: null,
dragOverFolderId: null,
@@ -813,7 +851,35 @@ export default {
}
},
},
+ mounted() {
+ // listen for contact updates
+ GlobalEmitter.on("contact-updated", this.onContactUpdated);
+ },
+ unmounted() {
+ GlobalEmitter.off("contact-updated", this.onContactUpdated);
+ },
methods: {
+ onContactUpdated(data) {
+ // update local contact info if context menu is showing this peer
+ if (this.contextMenu.show && this.contextMenu.targetHash === data.remote_identity_hash) {
+ this.fetchContactForContextMenu(data.remote_identity_hash);
+ }
+ },
+ async fetchContactForContextMenu(hash) {
+ try {
+ const response = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
+ if (response.data.is_contact) {
+ this.contextMenu.targetContact = response.data.contact;
+ } else {
+ this.contextMenu.targetContact = null;
+ }
+ } catch (e) {
+ console.error("Failed to fetch contact for context menu", e);
+ }
+ },
+ stripMarkdown(text) {
+ return MarkdownRenderer.strip(text);
+ },
toggleSelectionMode() {
this.selectionMode = !this.selectionMode;
if (!this.selectionMode) {
@@ -834,7 +900,7 @@ export default {
this.selectedHashes.add(hash);
}
},
- onRightClick(event, hash) {
+ async onRightClick(event, hash) {
event.preventDefault();
if (this.selectionMode && !this.selectedHashes.has(hash)) {
this.selectedHashes.add(hash);
@@ -843,6 +909,10 @@ export default {
this.contextMenu.y = event.clientY;
this.contextMenu.targetHash = hash;
this.contextMenu.show = true;
+ this.contextMenu.targetContact = null;
+
+ // fetch contact info for trust status
+ await this.fetchContactForContextMenu(hash);
},
onFolderContextMenu(event) {
event.preventDefault();
@@ -897,6 +967,38 @@ export default {
this.$emit("delete-folder", folder.id);
}
},
+ async toggleTelemetryTrust(hash) {
+ const contact = this.contextMenu.targetContact;
+ const newStatus = !contact?.is_telemetry_trusted;
+ try {
+ if (!contact) {
+ // find display name from conversations
+ const conv = this.conversations.find((c) => c.destination_hash === hash);
+ await window.axios.post("/api/v1/telephone/contacts", {
+ name: conv?.display_name || hash.substring(0, 8),
+ remote_identity_hash: hash,
+ is_telemetry_trusted: true,
+ });
+ } else {
+ await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, {
+ is_telemetry_trusted: newStatus,
+ });
+ }
+ GlobalEmitter.emit("contact-updated", {
+ remote_identity_hash: hash,
+ is_telemetry_trusted: newStatus,
+ });
+ this.contextMenu.show = false;
+ DialogUtils.alert(
+ newStatus
+ ? this.$t("app.telemetry_trust_granted_alert")
+ : this.$t("app.telemetry_trust_revoked_alert")
+ );
+ } catch (e) {
+ DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
+ console.error(e);
+ }
+ },
bulkMarkAsRead() {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("bulk-mark-as-read", hashes);

diff --git a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue b/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
index 7839bc4e..92937626 100644
--- a/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
+++ b/meshchatx/src/frontend/components/messages/PaperMessageModal.vue
@@ -277,38 +277,37 @@ export default {
}
},
async sendPaperMessage() {
- const canvas = this.$refs.qrcode;
- if (!canvas || !this.recipientHash || !this.uri) return;
+ if (!this.recipientHash || !this.uri) return;
try {
this.isSending = true;
- // get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
- const dataUrl = canvas.toDataURL("image/png");
-
- // extract base64 data by removing the prefix
- const imageBytes = dataUrl.split(",")[1];
-
// build lxmf message
const lxmf_message = {
destination_hash: this.recipientHash,
- content: this.uri,
- fields: {
- image: {
- image_type: "png",
- image_bytes: imageBytes,
- name: "qrcode.png",
- },
- },
+ content: `Please scan the attached Paper Message or manually ingest this URI: ${this.uri}`,
+ fields: {},
};
+ // get data url from canvas if available
+ const canvas = this.$refs.qrcode;
+ if (canvas) {
+ const dataUrl = canvas.toDataURL("image/png");
+ const imageBytes = dataUrl.split(",")[1];
+ lxmf_message.fields.image = {
+ image_type: "png",
+ image_bytes: imageBytes,
+ name: "paper_message_qr.png",
+ };
+ }
+
// send message
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
delivery_method: "opportunistic",
lxmf_message: lxmf_message,
});
- if (response.data.status === "success") {
+ if (response.data.lxmf_message) {
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.close();
} else {

diff --git a/meshchatx/src/frontend/components/ping/PingPage.vue b/meshchatx/src/frontend/components/ping/PingPage.vue
index 7f1cca39..da6d4e10 100644
--- a/meshchatx/src/frontend/components/ping/PingPage.vue
+++ b/meshchatx/src/frontend/components/ping/PingPage.vue
@@ -171,6 +171,14 @@ export default {
beforeUnmount() {
this.stop();
},
+ mounted() {
+ if (this.$route.query.hash) {
+ this.destinationHash = this.$route.query.hash;
+ if (this.$route.query.autostart === "1" || this.$route.query.autostart === "true") {
+ this.start();
+ }
+ }
+ },
methods: {
async start() {
// do nothing if already running

diff --git a/meshchatx/src/frontend/components/rncp/RNCPPage.vue b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
index 21227b84..43532fec 100644
--- a/meshchatx/src/frontend/components/rncp/RNCPPage.vue
+++ b/meshchatx/src/frontend/components/rncp/RNCPPage.vue
@@ -28,14 +28,17 @@
<!-- eslint-disable vue/no-v-html -->
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ @click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_1'))"
></p>
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ @click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_2'))"
></p>
<p
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
+ @click="handleMessageClick"
v-html="renderMarkdown($t('rncp.step_3'))"
></p>
<!-- eslint-enable vue/no-v-html -->
@@ -336,6 +339,7 @@
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
+import MarkdownRenderer from "../../js/MarkdownRenderer";
export default {
name: "RNCPPage",
@@ -520,8 +524,23 @@ export default {
this.listenResult = null;
},
renderMarkdown(text) {
- if (!text) return "";
- return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
+ return MarkdownRenderer.render(text);
+ },
+ handleMessageClick(event) {
+ const nomadnetLink = event.target.closest(".nomadnet-link");
+ if (nomadnetLink) {
+ event.preventDefault();
+ const url = nomadnetLink.getAttribute("data-nomadnet-url");
+ if (url) {
+ const [hash, ...pathParts] = url.split(":");
+ const path = pathParts.join(":");
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: hash },
+ query: { path: path },
+ });
+ }
+ }
},
},
};

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index e3a09204..a95b4d7a 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -106,7 +106,7 @@
<input
v-model="searchQuery"
type="text"
- class="w-full bg-white/80 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
+ class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
:placeholder="$t('app.search_settings') || 'Search settings...'"
/>
<button
@@ -687,6 +687,165 @@
</div>
</section>
+ <!-- Location -->
+ <section v-show="matchesSearch(...sectionKeywords.location)" class="glass-card break-inside-avoid">
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">Privacy</div>
+ <h2>Location</h2>
+ <p>Manage how your location is shared.</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">Location Source</div>
+ <select
+ v-model="config.location_source"
+ class="input-field"
+ @change="
+ updateConfig({ location_source: config.location_source }, 'location_source')
+ "
+ >
+ <option value="browser">Automatic (Browser)</option>
+ <option value="manual">Manual</option>
+ </select>
+ <div
+ v-if="config.location_source === 'browser'"
+ class="text-xs text-gray-600 dark:text-gray-400"
+ >
+ Uses your browser's geolocation API. Note: In the desktop app, this can use Google
+ services, which is blocked by CORS so you would need to specifically allow it.
+ </div>
+ <div
+ v-if="config.location_source === 'manual'"
+ class="text-xs text-gray-600 dark:text-gray-400"
+ >
+ Use manually entered coordinates for maximum privacy.
+ </div>
+ </div>
+
+ <div
+ v-if="config.location_source === 'manual'"
+ class="grid grid-cols-1 sm:grid-cols-3 gap-4"
+ >
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.location_manual_lat") }}
+ </div>
+ <input
+ v-model="config.location_manual_lat"
+ type="text"
+ class="input-field"
+ placeholder="0.0"
+ @input="
+ updateConfig(
+ { location_manual_lat: config.location_manual_lat },
+ 'location_manual_lat'
+ )
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.location_manual_lon") }}
+ </div>
+ <input
+ v-model="config.location_manual_lon"
+ type="text"
+ class="input-field"
+ placeholder="0.0"
+ @input="
+ updateConfig(
+ { location_manual_lon: config.location_manual_lon },
+ 'location_manual_lon'
+ )
+ "
+ />
+ </div>
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.location_manual_alt") }}
+ </div>
+ <input
+ v-model="config.location_manual_alt"
+ type="text"
+ class="input-field"
+ placeholder="0.0"
+ @input="
+ updateConfig(
+ { location_manual_alt: config.location_manual_alt },
+ 'location_manual_alt'
+ )
+ "
+ />
+ </div>
+ </div>
+
+ <div class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4">
+ <label class="setting-toggle">
+ <Toggle
+ id="telemetry-enabled"
+ v-model="config.telemetry_enabled"
+ @update:model-value="
+ updateConfig(
+ { telemetry_enabled: config.telemetry_enabled },
+ 'telemetry_enabled'
+ )
+ "
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{ $t("app.telemetry_enabled") }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.telemetry_description")
+ }}</span>
+ </span>
+ </label>
+ </div>
+
+ <div
+ v-if="config.telemetry_enabled"
+ class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4"
+ >
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.telemetry_trusted_peers") }}
+ </div>
+ <div v-if="trustedTelemetryPeers.length === 0" class="text-xs text-gray-500 italic">
+ {{ $t("app.telemetry_no_trusted_peers") }}
+ </div>
+ <div v-else class="space-y-2">
+ <div
+ v-for="peer in trustedTelemetryPeers"
+ :key="peer.id"
+ class="flex items-center justify-between p-2 rounded-xl bg-gray-50 dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700"
+ >
+ <div class="flex items-center gap-3">
+ <div
+ class="size-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
+ >
+ <MaterialDesignIcon icon-name="account" class="size-5" />
+ </div>
+ <div class="min-w-0">
+ <div class="text-sm font-bold text-gray-900 dark:text-white truncate">
+ {{ peer.name }}
+ </div>
+ <div class="text-[10px] text-gray-500 font-mono truncate">
+ {{ peer.remote_identity_hash }}
+ </div>
+ </div>
+ </div>
+ <button
+ class="p-2 text-gray-400 hover:text-red-500 transition-colors"
+ :title="$t('app.telemetry_revoke_trust')"
+ @click="revokeTelemetryTrust(peer)"
+ >
+ <MaterialDesignIcon icon-name="shield-off-outline" class="size-5" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Language -->
<section
v-show="
@@ -713,6 +872,7 @@
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="ru">Русский</option>
+ <option value="it">Italiano</option>
</select>
</div>
</section>
@@ -959,6 +1119,98 @@
</div>
</section>
+ <!-- Content Security Policy (CSP) -->
+ <section v-show="matchesSearch(...sectionKeywords.csp)" class="glass-card break-inside-avoid">
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">Security</div>
+ <h2>{{ $t("app.csp_settings") }}</h2>
+ <p>{{ $t("app.csp_description") }}</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.csp_extra_connect_src") }}
+ </div>
+ <input
+ v-model="config.csp_extra_connect_src"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="https://api.example.com, wss://socket.example.com"
+ @input="onCspConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.csp_extra_connect_src_description") }}
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.csp_extra_img_src") }}
+ </div>
+ <input
+ v-model="config.csp_extra_img_src"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="https://tiles.example.com, https://cdn.example.com"
+ @input="onCspConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.csp_extra_img_src_description") }}
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.csp_extra_frame_src") }}
+ </div>
+ <input
+ v-model="config.csp_extra_frame_src"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="https://video.example.com"
+ @input="onCspConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.csp_extra_frame_src_description") }}
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.csp_extra_script_src") }}
+ </div>
+ <input
+ v-model="config.csp_extra_script_src"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="https://scripts.example.com"
+ @input="onCspConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.csp_extra_script_src_description") }}
+ </div>
+ </div>
+
+ <div class="space-y-2">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.csp_extra_style_src") }}
+ </div>
+ <input
+ v-model="config.csp_extra_style_src"
+ type="text"
+ class="input-field font-mono text-xs"
+ placeholder="https://fonts.example.com"
+ @input="onCspConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ {{ $t("app.csp_extra_style_src_description") }}
+ </div>
+ </div>
+ </div>
+ </section>
+
<!-- Messages -->
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -1256,13 +1508,24 @@ export default {
message_icon_size: 28,
telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50,
+ location_source: "browser",
+ location_manual_lat: "0.0",
+ location_manual_lon: "0.0",
+ location_manual_alt: "0.0",
+ telemetry_enabled: false,
gitea_base_url: "https://git.quad4.io",
docs_download_urls: "",
+ csp_extra_connect_src: "",
+ csp_extra_img_src: "",
+ csp_extra_frame_src: "",
+ csp_extra_script_src: "",
+ csp_extra_style_src: "",
},
saveTimeouts: {},
shortcuts: [],
reloadingRns: false,
searchQuery: "",
+ trustedTelemetryPeers: [],
sectionKeywords: {
banishment: [
"Visuals",
@@ -1305,6 +1568,18 @@ export default {
],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
+ csp: [
+ "Security",
+ "app.csp_settings",
+ "app.csp_description",
+ "app.csp_extra_connect_src",
+ "app.csp_extra_img_src",
+ "app.csp_extra_frame_src",
+ "app.csp_extra_script_src",
+ "app.csp_extra_style_src",
+ "CSP",
+ "Content Security Policy",
+ ],
appearance: [
"Personalise",
"app.appearance",
@@ -1374,6 +1649,17 @@ export default {
"app.propagation_stamp_cost",
"app.propagation_stamp_description",
],
+ location: [
+ "Location",
+ "GPS",
+ "Privacy",
+ "manual",
+ "latitude",
+ "longitude",
+ "altitude",
+ "telemetry",
+ "trusted peers",
+ ],
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
},
};
@@ -1391,6 +1677,10 @@ export default {
lxmf_address_hash: "",
theme: "dark",
is_transport_enabled: false,
+ location_source: "browser",
+ location_manual_lat: "0.0",
+ location_manual_lon: "0.0",
+ location_manual_alt: "0.0",
};
}
return this.config;
@@ -1419,8 +1709,29 @@ export default {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
+ this.getTrustedTelemetryPeers();
},
methods: {
+ async getTrustedTelemetryPeers() {
+ try {
+ const response = await window.axios.get("/api/v1/telemetry/trusted-peers");
+ this.trustedTelemetryPeers = response.data.trusted_peers;
+ } catch (e) {
+ console.error("Failed to fetch trusted telemetry peers", e);
+ }
+ },
+ async revokeTelemetryTrust(peer) {
+ try {
+ await window.axios.patch(`/api/v1/telephone/contacts/${peer.id}`, {
+ is_telemetry_trusted: false,
+ });
+ this.getTrustedTelemetryPeers();
+ ToastUtils.success(this.$t("app.telemetry_trust_revoked", { name: peer.name }));
+ } catch (e) {
+ ToastUtils.error("Failed to revoke telemetry trust");
+ console.error(e);
+ }
+ },
matchesSearch(...texts) {
if (!this.searchQuery) return true;
const query = this.searchQuery.toLowerCase();
@@ -1787,6 +2098,21 @@ export default {
);
}, 1000);
},
+ async onCspConfigChange() {
+ if (this.saveTimeouts.csp) clearTimeout(this.saveTimeouts.csp);
+ this.saveTimeouts.csp = setTimeout(async () => {
+ await this.updateConfig(
+ {
+ csp_extra_connect_src: this.config.csp_extra_connect_src,
+ csp_extra_img_src: this.config.csp_extra_img_src,
+ csp_extra_frame_src: this.config.csp_extra_frame_src,
+ csp_extra_script_src: this.config.csp_extra_script_src,
+ csp_extra_style_src: this.config.csp_extra_style_src,
+ },
+ "csp_settings"
+ );
+ }, 1000);
+ },
async onBackupConfigChange() {
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
this.saveTimeouts.backup = setTimeout(async () => {

diff --git a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
index b0c82307..c10b15ac 100644
--- a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
+++ b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
@@ -267,12 +267,7 @@ export default {
}
} else if (json.type === "lxm.ingest_uri.result") {
if (json.status === "success") {
- ToastUtils.success(json.message);
this.ingestUri = "";
- } else if (json.status === "error") {
- ToastUtils.error(json.message);
- } else {
- ToastUtils.warning(json.message);
}
}
},
@@ -384,7 +379,7 @@ export default {
lxmf_message: lxmf_message,
});
- if (response.data.status === "success") {
+ if (response.data.lxmf_message) {
ToastUtils.success(this.$t("messages.paper_message_sent"));
this.generatedUri = null;
this.destinationHash = "";

diff --git a/meshchatx/src/frontend/components/tools/RNPathTracePage.vue b/meshchatx/src/frontend/components/tools/RNPathTracePage.vue
new file mode 100644
index 00000000..78fdf806
--- /dev/null
+++ b/meshchatx/src/frontend/components/tools/RNPathTracePage.vue
@@ -0,0 +1,377 @@
+<template>
+ <div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
+ <!-- header -->
+ <div class="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm z-10">
+ <div class="px-4 py-3 md:px-6 md:py-4 flex items-center justify-between gap-4">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl shrink-0">
+ <MaterialDesignIcon
+ icon-name="map-marker-path"
+ class="size-5 md:size-6 text-indigo-600 dark:text-indigo-400"
+ />
+ </div>
+ <div class="min-w-0">
+ <h1 class="text-lg md:text-xl font-bold text-gray-900 dark:text-white truncate">
+ {{ $t("tools.rnpath_trace.title") }}
+ </h1>
+ <p class="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 truncate">
+ {{ $t("tools.rnpath_trace.description") }}
+ </p>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-2">
+ <button
+ v-if="traceResult"
+ class="p-2 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400 transition-colors shrink-0"
+ title="Refresh Trace"
+ @click="runTrace"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-5" :class="{ 'animate-spin': isLoading }" />
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex-1 overflow-y-auto">
+ <div class="p-4 md:p-6 lg:p-8 max-w-5xl mx-auto space-y-6">
+ <!-- main input card -->
+ <div class="glass-card p-4 md:p-6">
+ <div class="flex items-center gap-3">
+ <div class="relative flex-1">
+ <input
+ v-model="destinationHash"
+ type="text"
+ placeholder="input destination hash"
+ class="w-full pl-4 pr-12 py-3 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-2xl text-sm md:text-base font-mono focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all dark:text-white shadow-inner"
+ @keyup.enter="runTrace"
+ />
+ <div
+ class="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-400 dark:text-zinc-500"
+ >
+ <MaterialDesignIcon icon-name="identifier" class="size-5" />
+ </div>
+ </div>
+ <button
+ class="size-12 md:size-14 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl flex items-center justify-center transition shadow-lg shadow-indigo-500/20 active:scale-95 disabled:opacity-50 shrink-0"
+ :disabled="!isValidHash || isLoading"
+ title="Trace Path"
+ @click="runTrace"
+ >
+ <MaterialDesignIcon
+ v-if="!isLoading"
+ icon-name="keyboard-return"
+ class="size-6 md:size-7"
+ />
+ <MaterialDesignIcon v-else icon-name="loading" class="size-6 animate-spin" />
+ </button>
+ </div>
+ </div>
+
+ <!-- results area -->
+ <div v-if="traceResult || isLoading" class="space-y-6">
+ <!-- loading state -->
+ <div v-if="isLoading" class="glass-card p-12 flex flex-col items-center justify-center gap-4">
+ <div class="relative">
+ <div
+ class="w-12 h-12 border-4 border-indigo-200 dark:border-indigo-900/30 border-t-indigo-600 rounded-full animate-spin"
+ ></div>
+ </div>
+ <div class="text-sm font-medium text-gray-600 dark:text-gray-400">
+ {{ $t("tools.rnpath_trace.tracing") }}
+ </div>
+ </div>
+
+ <!-- error state -->
+ <div
+ v-else-if="error"
+ class="glass-card p-6 border-l-4 border-l-red-500 bg-red-50/50 dark:bg-red-900/10"
+ >
+ <div class="flex items-start gap-3 text-red-600 dark:text-red-400">
+ <MaterialDesignIcon icon-name="alert-circle" class="size-5 md:size-6 shrink-0 mt-0.5" />
+ <div class="space-y-1">
+ <div class="font-bold text-sm md:text-base">Trace Error</div>
+ <div class="text-xs md:text-sm opacity-90 break-all whitespace-pre-wrap font-mono">
+ {{ error }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- success state -->
+ <div
+ v-else-if="traceResult"
+ class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"
+ >
+ <!-- compact summary bar -->
+ <div class="glass-card p-1 overflow-hidden">
+ <div class="flex flex-wrap items-center divide-x divide-gray-100 dark:divide-zinc-800">
+ <div class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center">
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
+ {{ $t("tools.rnpath_trace.total_hops") }}
+ </div>
+ <div class="text-xl md:text-2xl font-black text-indigo-600 dark:text-indigo-400">
+ {{ traceResult.hops }}
+ </div>
+ </div>
+ <div class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center">
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
+ {{ $t("tools.rnpath_trace.interface") }}
+ </div>
+ <div
+ class="text-xs md:text-sm font-bold text-gray-700 dark:text-zinc-200 truncate max-w-full"
+ >
+ {{ traceResult.interface || "None" }}
+ </div>
+ </div>
+ <div
+ class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center hidden sm:flex"
+ >
+ <div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
+ {{ $t("tools.rnpath_trace.next_hop") }}
+ </div>
+ <div
+ class="text-[10px] md:text-xs font-mono font-bold text-gray-700 dark:text-zinc-300 truncate max-w-full"
+ >
+ {{ traceResult.next_hop || "N/A" }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- path visualization -->
+ <div class="glass-card p-6 md:p-10 lg:p-16">
+ <!-- Desktop View (Horizontal) -->
+ <div class="hidden md:flex items-start justify-center min-w-fit py-4">
+ <template v-for="(node, idx) in traceResult.path" :key="'d-' + idx">
+ <!-- node item -->
+ <div class="flex flex-col items-center group relative w-32 shrink-0">
+ <div
+ class="w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 shadow-md group-hover:shadow-indigo-500/20 group-hover:scale-110 z-10"
+ :class="getNodeClass(node)"
+ >
+ <MaterialDesignIcon :icon-name="getNodeIcon(node)" class="size-7" />
+ </div>
+ <div class="mt-4 text-center px-2 w-full">
+ <div class="text-[11px] font-bold text-gray-900 dark:text-white truncate">
+ {{
+ node.name ||
+ formatHash(node.hash) ||
+ (node.type === "unknown"
+ ? $t("tools.rnpath_trace.unknown_hops", { count: node.count })
+ : "")
+ }}
+ </div>
+ <div
+ v-if="node.interface"
+ class="text-[9px] text-indigo-500 font-mono font-bold mt-0.5 truncate"
+ >
+ {{ node.interface }}
+ </div>
+ </div>
+
+ <!-- tooltip -->
+ <div
+ v-if="node.hash"
+ class="absolute -top-10 left-1/2 -translate-x-1/2 bg-zinc-900 dark:bg-zinc-800 text-white text-[10px] px-2 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all shadow-xl border border-zinc-700 pointer-events-none font-mono whitespace-nowrap z-20"
+ >
+ {{ node.hash }}
+ </div>
+ </div>
+
+ <!-- connector -->
+ <div
+ v-if="idx < traceResult.path.length - 1"
+ class="flex-1 min-w-[40px] max-w-[100px] mt-7 h-0.5 relative"
+ >
+ <div
+ class="absolute inset-0 bg-indigo-500/30"
+ :class="{
+ 'border-t-2 border-dashed border-indigo-300 dark:border-indigo-800 bg-transparent h-0':
+ traceResult.path[idx + 1].type === 'unknown' ||
+ node.type === 'unknown',
+ }"
+ ></div>
+ <div
+ v-if="
+ traceResult.path[idx + 1].type !== 'unknown' && node.type !== 'unknown'
+ "
+ class="absolute right-0 -top-1 w-2 h-2 rounded-full bg-indigo-500 shadow-sm shadow-indigo-500/50"
+ ></div>
+ </div>
+ </template>
+ </div>
+
+ <!-- Mobile View (Vertical) -->
+ <div class="md:hidden space-y-0">
+ <template v-for="(node, idx) in traceResult.path" :key="'m-' + idx">
+ <div class="flex gap-4">
+ <!-- timeline axis -->
+ <div class="flex flex-col items-center w-10 shrink-0">
+ <div
+ class="w-10 h-10 rounded-xl flex items-center justify-center shadow-md z-10"
+ :class="getNodeClass(node)"
+ >
+ <MaterialDesignIcon :icon-name="getNodeIcon(node)" class="size-5" />
+ </div>
+ <div
+ v-if="idx < traceResult.path.length - 1"
+ class="w-0.5 flex-1 min-h-[40px] my-1"
+ :class="
+ traceResult.path[idx + 1].type === 'unknown' ||
+ node.type === 'unknown'
+ ? 'border-l-2 border-dashed border-indigo-300 dark:border-indigo-800'
+ : 'bg-indigo-500/30'
+ "
+ ></div>
+ </div>
+
+ <!-- content -->
+ <div class="flex-1 pb-6 pt-1 min-w-0">
+ <div class="font-bold text-sm text-gray-900 dark:text-white truncate">
+ {{
+ node.name ||
+ (node.type === "unknown"
+ ? $t("tools.rnpath_trace.unknown_hops", { count: node.count })
+ : formatHash(node.hash))
+ }}
+ </div>
+ <div
+ v-if="node.hash"
+ class="text-[10px] font-mono text-gray-500 dark:text-gray-400 mt-0.5 truncate"
+ >
+ {{ node.hash }}
+ </div>
+ <div
+ v-if="node.interface"
+ class="inline-flex items-center gap-1 mt-1.5 px-2 py-0.5 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400 rounded text-[9px] font-bold uppercase tracking-wider"
+ >
+ <MaterialDesignIcon icon-name="router-wireless" class="size-3" />
+ {{ node.interface }}
+ </div>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+
+ <!-- action buttons -->
+ <div class="flex flex-col sm:flex-row items-center justify-center gap-3">
+ <button
+ class="w-full sm:w-auto px-6 py-3 bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded-2xl font-bold transition flex items-center justify-center gap-2 text-sm"
+ @click="pingDestination"
+ >
+ <MaterialDesignIcon icon-name="radar" class="size-5" />
+ {{ $t("tools.rnpath_trace.ping_test") }}
+ </button>
+ <button
+ class="w-full sm:w-auto px-6 py-3 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300 rounded-2xl font-bold transition flex items-center justify-center gap-2 text-sm"
+ @click="copyDestinationHash"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-5" />
+ {{ $t("common.copy_to_clipboard") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import ToastUtils from "../../js/ToastUtils";
+
+export default {
+ name: "RNPathTracePage",
+ components: {
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ destinationHash: "",
+ isLoading: false,
+ traceResult: null,
+ error: null,
+ };
+ },
+ computed: {
+ isValidHash() {
+ return /^[0-9a-f]{32}$/i.test(this.destinationHash);
+ },
+ },
+ mounted() {
+ if (this.$route.query.hash) {
+ this.destinationHash = this.$route.query.hash;
+ if (this.isValidHash) {
+ this.runTrace();
+ }
+ }
+ },
+ methods: {
+ async runTrace() {
+ if (!this.isValidHash) return;
+
+ this.isLoading = true;
+ this.error = null;
+ this.traceResult = null;
+
+ try {
+ const res = await window.axios.get(`/api/v1/rnpath/trace/${this.destinationHash}`);
+ if (res.data.error) {
+ this.error = res.data.error;
+ } else {
+ this.traceResult = res.data;
+ }
+ } catch (e) {
+ console.error(e);
+ this.error =
+ e.response?.data?.error ||
+ e.response?.data?.message ||
+ "Failed to communicate with the backend handler.";
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ getNodeClass(node) {
+ if (node.type === "local") return "bg-blue-600 text-white";
+ if (node.type === "destination") return "bg-emerald-600 text-white";
+ if (node.type === "unknown")
+ return "bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-gray-600 border-2 border-dashed border-gray-200 dark:border-zinc-700 shadow-none";
+ return "bg-indigo-600 text-white";
+ },
+ getNodeIcon(node) {
+ if (node.type === "local") return "home";
+ if (node.type === "destination") return "flag-variant";
+ if (node.type === "unknown") return "dots-horizontal";
+ return "router-wireless";
+ },
+ formatHash(hash) {
+ if (!hash) return "";
+ return hash.substring(0, 8) + "...";
+ },
+ pingDestination() {
+ this.$router.push({
+ name: "ping",
+ query: {
+ hash: this.destinationHash,
+ autostart: "1",
+ },
+ });
+ },
+ copyDestinationHash() {
+ if (this.destinationHash) {
+ navigator.clipboard.writeText(this.destinationHash);
+ ToastUtils.success(this.$t("common.copied"));
+ }
+ },
+ },
+};
+</script>
+
+<style scoped>
+.glass-card {
+ @apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl shadow-slate-200/20 dark:shadow-none;
+}
+</style>

diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index d2fd5293..6f0bfe7c 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -4,30 +4,41 @@
>
<div class="flex-1 overflow-y-auto w-full">
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
- <div class="glass-card space-y-3">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- {{ $t("tools.utilities") }}
- </div>
- <div class="text-2xl font-semibold text-gray-900 dark:text-white">
- {{ $t("tools.power_tools") }}
- </div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- {{ $t("tools.diagnostics_description") }}
+ <div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
+ <div class="space-y-3 flex-1 min-w-0">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("tools.utilities") }}
+ </div>
+ <div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
+ {{ $t("tools.power_tools") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
+ {{ $t("tools.diagnostics_description") }}
+ </div>
</div>
- </div>
- <div class="glass-card">
- <div class="relative">
- <MaterialDesignIcon
- icon-name="magnify"
- class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
- />
- <input
- v-model="searchQuery"
- type="text"
- :placeholder="$t('common.search')"
- class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
- />
+ <div class="w-full md:w-80 shrink-0">
+ <div class="relative group">
+ <div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
+ />
+ </div>
+ <input
+ v-model="searchQuery"
+ type="text"
+ :placeholder="$t('common.search')"
+ class="w-full pl-12 pr-10 py-3.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm transition-all text-sm"
+ />
+ <button
+ v-if="searchQuery"
+ class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+ @click="searchQuery = ''"
+ >
+ <MaterialDesignIcon icon-name="close-circle" class="size-5" />
+ </button>
+ </div>
</div>
</div>
@@ -147,6 +158,14 @@ export default {
titleKey: "tools.rnpath.title",
descriptionKey: "tools.rnpath.description",
},
+ {
+ name: "rnpath-trace",
+ route: { name: "rnpath-trace" },
+ icon: "map-marker-path",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.rnpath_trace.title",
+ descriptionKey: "tools.rnpath_trace.description",
+ },
{
name: "translator",
route: { name: "translator" },


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────